diff --git a/.config/nextest.toml b/.config/nextest.toml new file mode 100644 index 0000000000..11ec94cc1a --- /dev/null +++ b/.config/nextest.toml @@ -0,0 +1,10 @@ +# Minimum cargo-nextest for this repository: GHSA-vc25-qg58-w57c is fixed in 0.9.150 and CI +# installs exactly that version, so an older nextest refuses to run (exit 92 REQUIRED_VERSION_NOT_MET). +nextest-version = "0.9.150" + +# These measurements require a quiet runner. Their in-process SERIAL mutex only +# serializes cargo test; nextest gives each test its own process. Reserve all slots +# so other tests cannot charge their CPU/render-worker time to a frame budget. +[[profile.default.overrides]] +filter = 'package(pdfcraft-ui-egui) & binary(perf)' +threads-required = "num-test-threads" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9d168c7aa6..94a4c545fe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,7 +82,7 @@ jobs: libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev libgtk-3-dev qpdf poppler-utils # cargo-deny for the deny step of `cargo xtask ci`, which requires it under CI. - tools: cargo-nextest@0.9.149,cargo-deny@0.20.2 + tools: cargo-nextest@0.9.150,cargo-deny@0.20.2 # A missing oracle fails the job instead of printing a skip notice that reads as a pass. linux-env: PDFCRAFT_ORACLES_REQUIRED=1 windows-env: PDFCRAFT_REQUIRE_WINRES=1 diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index f54b0538b9..ea61d84303 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -21,3 +21,27 @@ jobs: lint: # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main + + windows-install-policy: + timeout-minutes: 10 + name: Windows installation policy (no app build) + runs-on: windows-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Install the pinned packaging toolchain + shell: pwsh + run: | + $tools = Join-Path $env:RUNNER_TEMP 'craft-install-policy-wix' + dotnet tool install --tool-path $tools wix --version 5.0.2 + if ($LASTEXITCODE -ne 0) { throw 'WiX installation failed.' } + $tools >> $env:GITHUB_PATH + $policy = Get-Content -Raw -LiteralPath packaging/windows/install-policy.json | ConvertFrom-Json + foreach ($extension in @($policy.extensions)) { + & (Join-Path $tools 'wix.exe') extension add -g "WixToolset.$extension.wixext/5.0.2" + if ($LASTEXITCODE -ne 0) { throw "WiX extension $extension installation failed." } + } + - name: Build and check non-executable installer fixtures + shell: pwsh + run: ./packaging/windows/test-install-policy.ps1 -OutputDirectory (Join-Path $env:RUNNER_TEMP 'craft-install-policy-tests') diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a5c2ba7f7f..d8549142f7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,6 +12,7 @@ on: permissions: contents: write + packages: write concurrency: group: release-${{ github.ref }} diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index d470419d26..f3e2b20c7e 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -32,3 +32,5 @@ jobs: with: app-name: pdfcraft app-dir: PdfCraft + install-relative-path: Programs/ArtCraft/PdfCraft + install-scope: perUser diff --git a/.gitignore b/.gitignore index 7834b668cb..f7922460c7 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ plan/ /assets/models # A craft-fonts checkout used as the optional CRAFT_FONTS_DIR build input (CI and releases put it here). /craft-fonts +.claude/ diff --git a/AGENTS.md b/AGENTS.md index 2ecfe99a02..cd6c0e2ca5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,8 +2,6 @@ These rules bind every AI agent and every human contributor working in this repository. They come before any task, instruction or deadline. `CLAUDE.md` holds the day-to-day working instructions. If the two ever disagree, **this file wins.** -Some sections link to **craftrules**, the standards shared by the Crafting Apps. That repository is internal to the storytold team, so outside contributors can't open it. Every rule a contributor here must follow is stated in this file and `CLAUDE.md`; the craftrules links give team members the background. - --- ## 1. Asset policy (absolute — no exceptions) @@ -69,9 +67,10 @@ Anything else is forbidden, including assets whose licence you cannot prove from ### 1.4 Fonts live in craft-fonts -- Font assets shared by the Crafting Apps, including every Japanese font, live in [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts), not here. **Never commit a font file to this repository**; adding a font means adding it to craft-fonts. (The Latin UI fonts already in `assets/fonts/` — Inter, JetBrains Mono, Dancing Script — stay.) Team members: the shared rules are in [craftrules `standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md) (internal). +- Font assets shared by the Crafting Apps, including every Japanese font, live in [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts), not here. **Never commit a font file to this repository**; adding a font means adding it to craft-fonts. (The Latin UI fonts already in `assets/fonts/` — Inter, JetBrains Mono, Dancing Script — stay.) - craft-fonts is an **optional build input**, never a Cargo dependency: `CRAFT_FONTS_DIR= cargo build` makes `crates/fonts/build.rs` embed its fonts as `pdfcraft_fonts::CRAFT_FONTS`. Without it PdfCraft builds, tests and runs, but has no Japanese face (Japanese UI text shows replacement boxes, and writing Japanese into a PDF returns a clear error). Release builds always set it (`.github/workflows/release.yml`), and `ATTRIBUTION.toml` lists it as a `[[build_input]]`. - The desktop app may read one font **already installed on the user's machine** at runtime, as the last interface fallback for characters no embedded face has (`crates/ui-egui/src/system_fonts.rs`). That font is never embedded, shipped, committed or copied, and never appears in published screenshots (`PDFCRAFT_SYSTEM_FONTS=0`, which `cargo xtask screenshots` sets). It is not a way around §1.2: nothing from it may enter a PDF, a package or the repository. +- The page viewer may also draw a CID font of an Adobe CJK collection (Japan1, GB1, CNS1, Korea1) that a PDF does **not embed** with one face already installed on the machine (`crates/render/src/system_font.rs`), **on screen only**. `RenderConfig::system_fonts` is off by default and is enabled only for on-screen viewer and preview rasters (`RenderConfig::on_screen`, used by the engine viewer, Combine thumbnails and accessibility figure previews), so exports, printing, OCR and the automation tools never use it. Like the interface font it is read at runtime and never embedded, shipped, committed or copied; Noto CJK and Source Han are never candidates (§1.1), nor is any Adobe family; and `PDFCRAFT_SYSTEM_FONTS=0` turns it off. It is not a way around §1.2 either: nothing drawn with it may be saved or printed. ### 1.5 Procedure for adding or changing an asset @@ -92,7 +91,7 @@ If you find an asset that breaks these rules, stop and remove it from the reposi - Behaviour comes only from public specifications (ISO 32000-2, the Arlington model), public documentation and black-box observation. - Never copy GPL/AGPL code. Copyleft tools run only as external oracle processes. - Observe Acrobat only with synthetic fixtures. Never capture personal data, account information or recent files. -- Shared real-file test oracles (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus), explained in [craftrules `standards/test-corpora.md`](https://github.com/storytold/craftrules/blob/main/standards/test-corpora.md) (internal). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`. +- Shared real-file test oracles (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`. ## 3. Agent control (automation and MCP) @@ -105,7 +104,7 @@ If you find an asset that breaks these rules, stop and remove it from the reposi ## 4. Never crash -PdfCraft opens files from strangers, and people trust it with their work. A malformed PDF, a bad tool argument, a corrupt settings file or a full disk must produce an error the user (or agent) can act on, never a crash and never lost work. **This outranks feature work:** don't ship a feature by adding a panic path, and fix a crash before building on top of it. The rules below are the ones that bind this repository; the shared standard for every Crafting App, [`craftrules/standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md), is internal to the storytold team. +PdfCraft opens files from strangers, and people trust it with their work. A malformed PDF, a bad tool argument, a corrupt settings file or a full disk must produce an error the user (or agent) can act on, never a crash and never lost work. **This outranks feature work:** don't ship a feature by adding a panic path, and fix a crash before building on top of it. The rules below are the ones that bind this repository. - **Fail with `Result`.** Non-test code returns errors through the crate's error type and `?`. Where readers are lenient by design, fall back gracefully and record the repair (see Fidelity in `CLAUDE.md`). - **No panicking shortcuts.** No `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!` or `unimplemented!` outside tests, unless the call is provably infallible (e.g. writing to a `Vec`), with a comment saying why. `assert!` only for internal invariants that input cannot reach. @@ -124,17 +123,19 @@ Follow `CLAUDE.md`. It covers quality gates, layering, commit rules and how to r ## Contributor credits (About window) - About ▸ Contributors/Models are compiled into the binary from `contributors/contributors.json` - (commit stats; generated, never hand-edit) and `contributors/people.toml` (names people chose for + (commit stats; never hand-edit) and `contributors/people.toml` (names people chose for themselves). See `docs/contributors.md`. - **Agents working for a contributor:** when you prepare a PR, check whether your human's GitHub username has a `[people.]` entry in `contributors/people.toml`. If not, ask them once whether they want to be credited by more than their username: a real name, a display name, and/or their public GitHub profile name (`sync_github_name = true`). If yes, add **only their own** entry - (copy the template at the top of the file, or run - `python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name`) + (copy the template at the top of the file) and include it in their PR, committed as them. If no, change nothing: they are credited as `@username` anyway. - Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or GitHub profiles). Never hand-edit `contributors.json`. -- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also - re-verifies who wrote each `people.toml` entry; `--check` only verifies). + +## CI workflows +- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here. +- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time. +- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it. diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index c5be9d7bc1..fc88e8b664 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -8,209 +8,209 @@ Every asset PdfCraft includes, bundles or uses to build its published material, | Asset | Title | Author | Licence | Source | Used for | |---|---|---|---|---|---| -| `crates/ui-egui/src/i18n/el.tsv` | Greek interface translation catalog | Camthalion77 and contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft English UI labels; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/zh-hans.tsv` | Simplified Chinese 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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/zh-hant.tsv` | Traditional Chinese (Taiwan) 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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/cs.tsv` | Czech 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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check and standard-identification wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/pt-br.tsv` | Brazilian Portuguese interface translation catalog | André Cardoso (andrecards), PdfCraft contributors | MIT OR Apache-2.0 | Clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources: the ~1,826-entry gist posted in issue #288 (https://gist.github.com/andrecards/ac388c25fba17312ce83207f8b7b467e), completed and reviewed by PdfCraft contributors; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table limit messages are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/es.tsv` | Spanish 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; the 2026-10-20 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/fr.tsv` | French 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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/de.tsv` | German 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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/lo.tsv` | Lao interface translation catalog | @bounkirdni-2025 | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Lao UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/te.tsv` | Telugu 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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/it.tsv` | Italian 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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/pl.tsv` | Polish 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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/ru.tsv` | Russian 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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/hu.tsv` | Hungarian 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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/bg.tsv` | Bulgarian 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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/kk.tsv` | Kazakh interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of the English UI labels into Kazakh, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/ja.tsv` | Japanese 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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/uk.tsv` | Ukrainian 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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/ar.tsv` | Arabic 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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/ckb.tsv` | Central Kurdish (Sorani) 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; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Central Kurdish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/fa.tsv` | Persian (Farsi) 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; 2026-10-25 initial catalog covering the current 2,776-entry key union, AI-assisted translations from English, not separately native-reviewed | Persian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/tr.tsv` | Turkish 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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/lt.tsv` | Lithuanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; original catalog reviewed by a native Lithuanian-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/hi.tsv` | Hindi 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; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Hindi UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/id.tsv` | Indonesian 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; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Indonesian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/ko.tsv` | Korean 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; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Korean UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/ro.tsv` | Romanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; AI-assisted and not yet reviewed line by line by a native Romanian speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Romanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/fi.tsv` | Finnish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: AI-assisted (Claude Code) clean-room translations of PdfCraft's English UI labels and user-facing messages; not yet proofread by a native Finnish speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 calculation-order, text-format, print, thumbnail, layer, comment-reshape and signature labels (previously English placeholders) are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Finnish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `assets/icons/accessibility.svg` | Lucide icon "accessibility" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/accessibility.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/align-left.svg` | Lucide icon "align-left" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-left.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/align-center.svg` | Lucide icon "align-center" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-center.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/square-terminal.svg` | Lucide icon "square-terminal" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/square-terminal.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/file-code.svg` | Lucide icon "file-code" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-code.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/file-spreadsheet.svg` | Lucide icon "file-spreadsheet" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-spreadsheet.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/align-justify.svg` | Lucide icon "align-justify" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-justify.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/align-right.svg` | Lucide icon "align-right" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-right.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/columns-3.svg` | Lucide icon "columns-3" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/columns-3.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/rows-3.svg` | Lucide icon "rows-3" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/rows-3.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/flip-horizontal-2.svg` | Lucide icon "flip-horizontal-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/flip-horizontal-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/flip-vertical-2.svg` | Lucide icon "flip-vertical-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/flip-vertical-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/app-window.svg` | Lucide icon "app-window" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/app-window.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/arrow-left-right.svg` | Lucide icon "arrow-left-right" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/arrow-left-right.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/arrow-up-down.svg` | Lucide icon "arrow-up-down" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/arrow-up-down.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/arrow-up-right.svg` | Lucide icon "arrow-up-right" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/arrow-up-right.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/badge-check.svg` | Lucide icon "badge-check" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/badge-check.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/bandage.svg` | Lucide icon "bandage" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/bandage.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/bell.svg` | Lucide icon "bell" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/bell.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/blend.svg` | Lucide icon "blend" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/blend.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/bold.svg` | Lucide icon "bold" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/bold.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/book-open.svg` | Lucide icon "book-open" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/book-open.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/bookmark.svg` | Lucide icon "bookmark" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/bookmark.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/bookmark-plus.svg` | Lucide icon "bookmark-plus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/bookmark-plus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/braces.svg` | Lucide icon "braces" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/braces.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/brush-cleaning.svg` | Lucide icon "brush-cleaning" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/brush-cleaning.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/brush.svg` | Lucide icon "brush" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/brush.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/camera.svg` | Lucide icon "camera" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/camera.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/check-circle-2.svg` | Lucide icon "check-circle-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/check-circle-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/check.svg` | Lucide icon "check" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/check.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/chevron-down.svg` | Lucide icon "chevron-down" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/chevron-down.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/chevron-left.svg` | Lucide icon "chevron-left" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/chevron-left.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/chevron-right.svg` | Lucide icon "chevron-right" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/chevron-right.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/chevron-up.svg` | Lucide icon "chevron-up" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/chevron-up.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/chevrons-left.svg` | Lucide icon "chevrons-left" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/chevrons-left.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/chevrons-right.svg` | Lucide icon "chevrons-right" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/chevrons-right.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/circle-check.svg` | Lucide icon "circle-check" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-check.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/circle-dashed.svg` | Lucide icon "circle-dashed" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-dashed.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/circle-dot.svg` | Lucide icon "circle-dot" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-dot.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/circle-help.svg` | Lucide icon "circle-help" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-help.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/circle.svg` | Lucide icon "circle" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/clock-3.svg` | Lucide icon "clock-3" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/clock-3.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/clock.svg` | Lucide icon "clock" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/clock.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/cloud-upload.svg` | Lucide icon "cloud-upload" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/cloud-upload.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/cloud.svg` | Lucide icon "cloud" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/cloud.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/code-xml.svg` | Lucide icon "code-xml" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/code-xml.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/columns-2.svg` | Lucide icon "columns-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/columns-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/compass.svg` | Lucide icon "compass" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/compass.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/contrast.svg` | Lucide icon "contrast" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/contrast.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/copy-plus.svg` | Lucide icon "copy-plus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/copy-plus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/copy.svg` | Lucide icon "copy" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/copy.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/crop.svg` | Lucide icon "crop" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/crop.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/droplet.svg` | Lucide icon "droplet" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/droplet.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/ellipsis-vertical.svg` | Lucide icon "ellipsis-vertical" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/ellipsis-vertical.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/ellipsis.svg` | Lucide icon "ellipsis" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/ellipsis.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/eraser.svg` | Lucide icon "eraser" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/eraser.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/external-link.svg` | Lucide icon "external-link" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/external-link.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/eye-off.svg` | Lucide icon "eye-off" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/eye-off.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/eye.svg` | Lucide icon "eye" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/eye.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/file-check.svg` | Lucide icon "file-check" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-check.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/file-down.svg` | Lucide icon "file-down" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-down.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/file-input.svg` | Lucide icon "file-input" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-input.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/file-lock-2.svg` | Lucide icon "file-lock-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-lock-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/file-minus.svg` | Lucide icon "file-minus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-minus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/file-output.svg` | Lucide icon "file-output" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-output.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/file-pen-line.svg` | Lucide icon "file-pen-line" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-pen-line.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/file-plus-2.svg` | Lucide icon "file-plus-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-plus-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/file-plus.svg` | Lucide icon "file-plus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-plus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/file-scan.svg` | Lucide icon "file-scan" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-scan.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/file-search.svg` | Lucide icon "file-search" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-search.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/file-symlink.svg` | Lucide icon "file-symlink" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-symlink.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/file-text.svg` | Lucide icon "file-text" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-text.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/file-x.svg` | Lucide icon "file-x" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-x.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/files.svg` | Lucide icon "files" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/files.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/film.svg` | Lucide icon "film" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/film.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/filter.svg` | Lucide icon "filter" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/filter.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/folder-open.svg` | Lucide icon "folder-open" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/folder-open.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/folder-plus.svg` | Lucide icon "folder-plus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/folder-plus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/folder.svg` | Lucide icon "folder" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/folder.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/gauge.svg` | Lucide icon "gauge" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/gauge.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/git-compare.svg` | Lucide icon "git-compare" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/git-compare.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/globe.svg` | Lucide icon "globe" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/globe.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/grid-3x3.svg` | Lucide icon "grid-3x3" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/grid-3x3.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/hand.svg` | Lucide icon "hand" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/hand.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/hash.svg` | Lucide icon "hash" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/hash.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/heading.svg` | Lucide icon "heading" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/heading.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/highlighter.svg` | Lucide icon "highlighter" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/highlighter.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/house.svg` | Lucide icon "house" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/house.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/image-plus.svg` | Lucide icon "image-plus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/image-plus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/image.svg` | Lucide icon "image" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/image.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/info.svg` | Lucide icon "info" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/info.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/italic.svg` | Lucide icon "italic" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/italic.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/languages.svg` | Lucide icon "languages" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/languages.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/lasso.svg` | Lucide icon "lasso" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/lasso.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/layers.svg` | Lucide icon "layers" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/layers.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/layout-grid.svg` | Lucide icon "layout-grid" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/layout-grid.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/link-2.svg` | Lucide icon "link-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/link-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/link.svg` | Lucide icon "link" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/link.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/list-checks.svg` | Lucide icon "list-checks" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/list-checks.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/list-ordered.svg` | Lucide icon "list-ordered" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/list-ordered.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/list.svg` | Lucide icon "list" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/list.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/lock-open.svg` | Lucide icon "lock-open" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/lock-open.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/lock.svg` | Lucide icon "lock" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/lock.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/maximize-2.svg` | Lucide icon "maximize-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/maximize-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/maximize.svg` | Lucide icon "maximize" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/maximize.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/message-circle-reply.svg` | Lucide icon "message-circle-reply" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-circle-reply.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/message-square-text.svg` | Lucide icon "message-square-text" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-square-text.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/message-square.svg` | Lucide icon "message-square" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-square.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/messages-square.svg` | Lucide icon "messages-square" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/messages-square.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/mic.svg` | Lucide icon "mic" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/mic.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/minimize.svg` | Lucide icon "minimize" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/minimize.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/minimize-2.svg` | Lucide icon "minimize-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/minimize-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/minus.svg` | Lucide icon "minus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/minus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/moon.svg` | Lucide icon "moon" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/moon.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/mouse-pointer-2.svg` | Lucide icon "mouse-pointer-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/mouse-pointer-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/move.svg` | Lucide icon "move" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/move.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/navigation.svg` | Lucide icon "navigation" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/navigation.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/paint-bucket.svg` | Lucide icon "paint-bucket" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/paint-bucket.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/palette.svg` | Lucide icon "palette" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/palette.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/panel-left.svg` | Lucide icon "panel-left" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/panel-left.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/panel-right.svg` | Lucide icon "panel-right" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/panel-right.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/panels-top-left.svg` | Lucide icon "panels-top-left" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/panels-top-left.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/paperclip.svg` | Lucide icon "paperclip" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/paperclip.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/pen-line.svg` | Lucide icon "pen-line" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pen-line.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/pen-tool.svg` | Lucide icon "pen-tool" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pen-tool.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/pencil.svg` | Lucide icon "pencil" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pencil.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/pipette.svg` | Lucide icon "pipette" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pipette.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/plus.svg` | Lucide icon "plus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/plus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/presentation.svg` | Lucide icon "presentation" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/presentation.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/printer.svg` | Lucide icon "printer" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/printer.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/rectangle-horizontal.svg` | Lucide icon "rectangle-horizontal" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/rectangle-horizontal.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/redo-2.svg` | Lucide icon "redo-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/redo-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/replace.svg` | Lucide icon "replace" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/replace.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/rotate-ccw.svg` | Lucide icon "rotate-ccw" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/rotate-ccw.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/rotate-cw.svg` | Lucide icon "rotate-cw" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/rotate-cw.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/ruler.svg` | Lucide icon "ruler" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/ruler.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/save.svg` | Lucide icon "save" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/save.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/scan-text.svg` | Lucide icon "scan-text" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/scan-text.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/scan.svg` | Lucide icon "scan" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/scan.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/scissors.svg` | Lucide icon "scissors" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/scissors.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/search.svg` | Lucide icon "search" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/search.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/send.svg` | Lucide icon "send" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/send.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/settings-2.svg` | Lucide icon "settings-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/settings-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/settings.svg` | Lucide icon "settings" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/settings.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/share-2.svg` | Lucide icon "share-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/share-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/shield-check.svg` | Lucide icon "shield-check" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/shield-check.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/signature.svg` | Lucide icon "signature" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/signature.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/sliders-horizontal.svg` | Lucide icon "sliders-horizontal" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/sliders-horizontal.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/sparkles.svg` | Lucide icon "sparkles" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/sparkles.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/spline.svg` | Lucide icon "spline" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/spline.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/square-dashed-mouse-pointer.svg` | Lucide icon "square-dashed-mouse-pointer" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/square-dashed-mouse-pointer.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/square-dashed.svg` | Lucide icon "square-dashed" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/square-dashed.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/square.svg` | Lucide icon "square" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/square.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/squares-subtract.svg` | Lucide icon "squares-subtract" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/squares-subtract.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/stamp.svg` | Lucide icon "stamp" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/stamp.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/star.svg` | Lucide icon "star" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/star.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/sticky-note.svg` | Lucide icon "sticky-note" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/sticky-note.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/strikethrough.svg` | Lucide icon "strikethrough" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/strikethrough.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/sun.svg` | Lucide icon "sun" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/sun.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/swatch-book.svg` | Lucide icon "swatch-book" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/swatch-book.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/tag.svg` | Lucide icon "tag" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/tag.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/text-cursor-input.svg` | Lucide icon "text-cursor-input" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/text-cursor-input.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/text-select.svg` | Lucide icon "text-select" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/text-select.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/trash-2.svg` | Lucide icon "trash-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/trash-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/trash.svg` | Lucide icon "trash" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/trash.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/triangle-alert.svg` | Lucide icon "triangle-alert" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/triangle-alert.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/type.svg` | Lucide icon "type" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/type.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/underline.svg` | Lucide icon "underline" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/underline.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/undo-2.svg` | Lucide icon "undo-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/undo-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/user-round.svg` | Lucide icon "user-round" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/user-round.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/wand-sparkles.svg` | Lucide icon "wand-sparkles" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/wand-sparkles.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/wrench.svg` | Lucide icon "wrench" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/wrench.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/x.svg` | Lucide icon "x" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/x.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/zoom-in.svg` | Lucide icon "zoom-in" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/zoom-in.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/zoom-out.svg` | Lucide icon "zoom-out" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/zoom-out.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `crates/ui-egui/src/i18n/el.tsv` | Greek interface translation catalog | Camthalion77 and contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft English UI labels; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Greek UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/zh-hans.tsv` | Simplified Chinese 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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/zh-hant.tsv` | Traditional Chinese (Taiwan) 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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/cs.tsv` | Czech 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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check and standard-identification wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Czech UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/pt-br.tsv` | Brazilian Portuguese interface translation catalog | André Cardoso (andrecards), PdfCraft contributors | MIT OR Apache-2.0 | Clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources: the ~1,826-entry gist posted in issue #288 (https://gist.github.com/andrecards/ac388c25fba17312ce83207f8b7b467e), completed and reviewed by PdfCraft contributors; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table limit messages are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/es.tsv` | Spanish 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; the 2026-10-20 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Spanish UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/fr.tsv` | French 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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | French UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/de.tsv` | German 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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | German UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/lo.tsv` | Lao interface translation catalog | @bounkirdni-2025 | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Lao UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/te.tsv` | Telugu 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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Telugu UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/it.tsv` | Italian 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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Italian UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/pl.tsv` | Polish 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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Polish UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/ru.tsv` | Russian 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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Russian UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/hu.tsv` | Hungarian 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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Hungarian UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/bg.tsv` | Bulgarian 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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/kk.tsv` | Kazakh interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of the English UI labels into Kazakh, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Kazakh UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/ja.tsv` | Japanese 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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Japanese UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/uk.tsv` | Ukrainian 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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/ar.tsv` | Arabic 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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Arabic UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/ckb.tsv` | Central Kurdish (Sorani) 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; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Central Kurdish UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/fa.tsv` | Persian (Farsi) 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; 2026-10-25 initial catalog covering the current 2,776-entry key union, AI-assisted translations from English, not separately native-reviewed | Persian UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/tr.tsv` | Turkish 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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Turkish UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/lt.tsv` | Lithuanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; original catalog reviewed by a native Lithuanian-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/hi.tsv` | Hindi 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; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Hindi UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/id.tsv` | Indonesian 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; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Indonesian UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/ko.tsv` | Korean 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; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Korean UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/ro.tsv` | Romanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; AI-assisted and not yet reviewed line by line by a native Romanian speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Romanian UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/fi.tsv` | Finnish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: AI-assisted (Claude Code) clean-room translations of PdfCraft's English UI labels and user-facing messages; not yet proofread by a native Finnish speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 calculation-order, text-format, print, thumbnail, layer, comment-reshape and signature labels (previously English placeholders) are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Finnish UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `assets/icons/accessibility.svg` | Lucide icon "accessibility" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/accessibility.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/align-left.svg` | Lucide icon "align-left" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-left.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/align-center.svg` | Lucide icon "align-center" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-center.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/square-terminal.svg` | Lucide icon "square-terminal" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/square-terminal.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/file-code.svg` | Lucide icon "file-code" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-code.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/file-spreadsheet.svg` | Lucide icon "file-spreadsheet" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-spreadsheet.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/align-justify.svg` | Lucide icon "align-justify" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-justify.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/align-right.svg` | Lucide icon "align-right" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-right.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/columns-3.svg` | Lucide icon "columns-3" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/columns-3.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/rows-3.svg` | Lucide icon "rows-3" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/rows-3.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/flip-horizontal-2.svg` | Lucide icon "flip-horizontal-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/flip-horizontal-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/flip-vertical-2.svg` | Lucide icon "flip-vertical-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/flip-vertical-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/app-window.svg` | Lucide icon "app-window" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/app-window.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/arrow-left-right.svg` | Lucide icon "arrow-left-right" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/arrow-left-right.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/arrow-up-down.svg` | Lucide icon "arrow-up-down" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/arrow-up-down.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/arrow-up-right.svg` | Lucide icon "arrow-up-right" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/arrow-up-right.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/badge-check.svg` | Lucide icon "badge-check" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/badge-check.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/bandage.svg` | Lucide icon "bandage" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/bandage.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/bell.svg` | Lucide icon "bell" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/bell.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/blend.svg` | Lucide icon "blend" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/blend.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/bold.svg` | Lucide icon "bold" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/bold.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/book-open.svg` | Lucide icon "book-open" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/book-open.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/bookmark.svg` | Lucide icon "bookmark" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/bookmark.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/bookmark-plus.svg` | Lucide icon "bookmark-plus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/bookmark-plus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/braces.svg` | Lucide icon "braces" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/braces.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/brush-cleaning.svg` | Lucide icon "brush-cleaning" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/brush-cleaning.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/brush.svg` | Lucide icon "brush" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/brush.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/camera.svg` | Lucide icon "camera" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/camera.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/check-circle-2.svg` | Lucide icon "check-circle-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/check-circle-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/check.svg` | Lucide icon "check" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/check.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/chevron-down.svg` | Lucide icon "chevron-down" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/chevron-down.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/chevron-left.svg` | Lucide icon "chevron-left" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/chevron-left.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/chevron-right.svg` | Lucide icon "chevron-right" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/chevron-right.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/chevron-up.svg` | Lucide icon "chevron-up" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/chevron-up.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/chevrons-left.svg` | Lucide icon "chevrons-left" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/chevrons-left.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/chevrons-right.svg` | Lucide icon "chevrons-right" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/chevrons-right.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/circle-check.svg` | Lucide icon "circle-check" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-check.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/circle-dashed.svg` | Lucide icon "circle-dashed" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-dashed.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/circle-dot.svg` | Lucide icon "circle-dot" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-dot.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/circle-help.svg` | Lucide icon "circle-help" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-help.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/circle.svg` | Lucide icon "circle" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/clock-3.svg` | Lucide icon "clock-3" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/clock-3.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/clock.svg` | Lucide icon "clock" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/clock.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/cloud-upload.svg` | Lucide icon "cloud-upload" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/cloud-upload.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/cloud.svg` | Lucide icon "cloud" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/cloud.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/code-xml.svg` | Lucide icon "code-xml" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/code-xml.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/columns-2.svg` | Lucide icon "columns-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/columns-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/compass.svg` | Lucide icon "compass" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/compass.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/contrast.svg` | Lucide icon "contrast" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/contrast.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/copy-plus.svg` | Lucide icon "copy-plus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/copy-plus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/copy.svg` | Lucide icon "copy" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/copy.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/crop.svg` | Lucide icon "crop" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/crop.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/droplet.svg` | Lucide icon "droplet" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/droplet.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/ellipsis-vertical.svg` | Lucide icon "ellipsis-vertical" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/ellipsis-vertical.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/ellipsis.svg` | Lucide icon "ellipsis" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/ellipsis.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/eraser.svg` | Lucide icon "eraser" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/eraser.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/external-link.svg` | Lucide icon "external-link" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/external-link.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/eye-off.svg` | Lucide icon "eye-off" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/eye-off.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/eye.svg` | Lucide icon "eye" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/eye.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/file-check.svg` | Lucide icon "file-check" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-check.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/file-down.svg` | Lucide icon "file-down" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-down.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/file-input.svg` | Lucide icon "file-input" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-input.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/file-lock-2.svg` | Lucide icon "file-lock-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-lock-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/file-minus.svg` | Lucide icon "file-minus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-minus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/file-output.svg` | Lucide icon "file-output" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-output.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/file-pen-line.svg` | Lucide icon "file-pen-line" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-pen-line.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/file-plus-2.svg` | Lucide icon "file-plus-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-plus-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/file-plus.svg` | Lucide icon "file-plus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-plus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/file-scan.svg` | Lucide icon "file-scan" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-scan.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/file-search.svg` | Lucide icon "file-search" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-search.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/file-symlink.svg` | Lucide icon "file-symlink" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-symlink.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/file-text.svg` | Lucide icon "file-text" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-text.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/file-x.svg` | Lucide icon "file-x" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-x.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/files.svg` | Lucide icon "files" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/files.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/film.svg` | Lucide icon "film" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/film.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/filter.svg` | Lucide icon "filter" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/filter.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/folder-open.svg` | Lucide icon "folder-open" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/folder-open.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/folder-plus.svg` | Lucide icon "folder-plus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/folder-plus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/folder.svg` | Lucide icon "folder" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/folder.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/gauge.svg` | Lucide icon "gauge" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/gauge.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/git-compare.svg` | Lucide icon "git-compare" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/git-compare.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/globe.svg` | Lucide icon "globe" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/globe.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/grid-3x3.svg` | Lucide icon "grid-3x3" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/grid-3x3.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/hand.svg` | Lucide icon "hand" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/hand.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/hash.svg` | Lucide icon "hash" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/hash.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/heading.svg` | Lucide icon "heading" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/heading.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/highlighter.svg` | Lucide icon "highlighter" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/highlighter.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/house.svg` | Lucide icon "house" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/house.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/image-plus.svg` | Lucide icon "image-plus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/image-plus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/image.svg` | Lucide icon "image" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/image.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/info.svg` | Lucide icon "info" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/info.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/italic.svg` | Lucide icon "italic" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/italic.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/languages.svg` | Lucide icon "languages" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/languages.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/lasso.svg` | Lucide icon "lasso" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/lasso.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/layers.svg` | Lucide icon "layers" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/layers.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/layout-grid.svg` | Lucide icon "layout-grid" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/layout-grid.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/link-2.svg` | Lucide icon "link-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/link-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/link.svg` | Lucide icon "link" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/link.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/list-checks.svg` | Lucide icon "list-checks" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/list-checks.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/list-ordered.svg` | Lucide icon "list-ordered" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/list-ordered.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/list.svg` | Lucide icon "list" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/list.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/lock-open.svg` | Lucide icon "lock-open" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/lock-open.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/lock.svg` | Lucide icon "lock" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/lock.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/maximize-2.svg` | Lucide icon "maximize-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/maximize-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/maximize.svg` | Lucide icon "maximize" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/maximize.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/message-circle-reply.svg` | Lucide icon "message-circle-reply" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-circle-reply.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/message-square-text.svg` | Lucide icon "message-square-text" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-square-text.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/message-square.svg` | Lucide icon "message-square" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-square.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/messages-square.svg` | Lucide icon "messages-square" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/messages-square.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/mic.svg` | Lucide icon "mic" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/mic.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/minimize.svg` | Lucide icon "minimize" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/minimize.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/minimize-2.svg` | Lucide icon "minimize-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/minimize-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/minus.svg` | Lucide icon "minus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/minus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/moon.svg` | Lucide icon "moon" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/moon.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/mouse-pointer-2.svg` | Lucide icon "mouse-pointer-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/mouse-pointer-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/move.svg` | Lucide icon "move" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/move.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/navigation.svg` | Lucide icon "navigation" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/navigation.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/paint-bucket.svg` | Lucide icon "paint-bucket" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/paint-bucket.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/palette.svg` | Lucide icon "palette" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/palette.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/panel-left.svg` | Lucide icon "panel-left" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/panel-left.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/panel-right.svg` | Lucide icon "panel-right" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/panel-right.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/panels-top-left.svg` | Lucide icon "panels-top-left" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/panels-top-left.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/paperclip.svg` | Lucide icon "paperclip" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/paperclip.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/pen-line.svg` | Lucide icon "pen-line" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pen-line.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/pen-tool.svg` | Lucide icon "pen-tool" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pen-tool.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/pencil.svg` | Lucide icon "pencil" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pencil.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/pipette.svg` | Lucide icon "pipette" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pipette.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/plus.svg` | Lucide icon "plus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/plus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/presentation.svg` | Lucide icon "presentation" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/presentation.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/printer.svg` | Lucide icon "printer" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/printer.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/rectangle-horizontal.svg` | Lucide icon "rectangle-horizontal" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/rectangle-horizontal.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/redo-2.svg` | Lucide icon "redo-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/redo-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/replace.svg` | Lucide icon "replace" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/replace.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/rotate-ccw.svg` | Lucide icon "rotate-ccw" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/rotate-ccw.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/rotate-cw.svg` | Lucide icon "rotate-cw" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/rotate-cw.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/ruler.svg` | Lucide icon "ruler" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/ruler.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/save.svg` | Lucide icon "save" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/save.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/scan-text.svg` | Lucide icon "scan-text" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/scan-text.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/scan.svg` | Lucide icon "scan" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/scan.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/scissors.svg` | Lucide icon "scissors" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/scissors.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/search.svg` | Lucide icon "search" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/search.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/send.svg` | Lucide icon "send" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/send.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/settings-2.svg` | Lucide icon "settings-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/settings-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/settings.svg` | Lucide icon "settings" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/settings.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/share-2.svg` | Lucide icon "share-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/share-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/shield-check.svg` | Lucide icon "shield-check" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/shield-check.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/signature.svg` | Lucide icon "signature" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/signature.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/sliders-horizontal.svg` | Lucide icon "sliders-horizontal" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/sliders-horizontal.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/sparkles.svg` | Lucide icon "sparkles" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/sparkles.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/spline.svg` | Lucide icon "spline" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/spline.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/square-dashed-mouse-pointer.svg` | Lucide icon "square-dashed-mouse-pointer" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/square-dashed-mouse-pointer.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/square-dashed.svg` | Lucide icon "square-dashed" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/square-dashed.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/square.svg` | Lucide icon "square" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/square.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/squares-subtract.svg` | Lucide icon "squares-subtract" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/squares-subtract.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/stamp.svg` | Lucide icon "stamp" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/stamp.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/star.svg` | Lucide icon "star" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/star.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/sticky-note.svg` | Lucide icon "sticky-note" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/sticky-note.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/strikethrough.svg` | Lucide icon "strikethrough" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/strikethrough.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/sun.svg` | Lucide icon "sun" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/sun.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/swatch-book.svg` | Lucide icon "swatch-book" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/swatch-book.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/tag.svg` | Lucide icon "tag" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/tag.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/text-cursor-input.svg` | Lucide icon "text-cursor-input" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/text-cursor-input.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/text-select.svg` | Lucide icon "text-select" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/text-select.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/trash-2.svg` | Lucide icon "trash-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/trash-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/trash.svg` | Lucide icon "trash" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/trash.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/triangle-alert.svg` | Lucide icon "triangle-alert" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/triangle-alert.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/type.svg` | Lucide icon "type" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/type.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/underline.svg` | Lucide icon "underline" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/underline.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/undo-2.svg` | Lucide icon "undo-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/undo-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/user-round.svg` | Lucide icon "user-round" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/user-round.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/wand-sparkles.svg` | Lucide icon "wand-sparkles" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/wand-sparkles.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/wrench.svg` | Lucide icon "wrench" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/wrench.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/x.svg` | Lucide icon "x" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/x.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/zoom-in.svg` | Lucide icon "zoom-in" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/zoom-in.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/zoom-out.svg` | Lucide icon "zoom-out" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/zoom-out.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/fonts/Inter-Regular.ttf` | Inter Regular | The Inter Project Authors (Rasmus Andersson) | OFL-1.1 | https://github.com/rsms/inter | UI font, embedded in the app; demo PDF labels; slanted fallback glyphs in Fill & Sign typed signatures for characters Dancing Script lacks (Cyrillic, Greek), drawn as filled paths | | `assets/fonts/Inter-Medium.ttf` | Inter Medium | The Inter Project Authors (Rasmus Andersson) | OFL-1.1 | https://github.com/rsms/inter | UI font, embedded in the app; demo PDF labels | | `assets/fonts/Inter-SemiBold.ttf` | Inter SemiBold | The Inter Project Authors (Rasmus Andersson) | OFL-1.1 | https://github.com/rsms/inter | UI font, embedded in the app; demo PDF labels | @@ -240,22 +240,22 @@ Every asset PdfCraft includes, bundles or uses to build its published material, | `docs/brand/artcraft-mark-black.svg` | ArtCraft brand: artcraft-mark-black | ArtCraft Team | LicenseRef-Storyteller-Trademark | ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft) | ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it. | | `docs/brand/artcraft-mark.png` | ArtCraft brand: artcraft-mark | ArtCraft Team | LicenseRef-Storyteller-Trademark | ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft) | ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it. | | `docs/brand/artcraft-mark.svg` | ArtCraft brand: artcraft-mark | ArtCraft Team | LicenseRef-Storyteller-Trademark | ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft) | ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it. | -| `assets/app-icon/pdfcraft.svg` | PdfCraft app icon (master vector) | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Master artwork; every other app-icon file is rendered from it (packaging/icons.sh) | -| `assets/app-icon/pdfcraft-small.svg` | PdfCraft app icon (light vector) | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | README header and assets/app-icon/README.md | -| `assets/app-icon/pdfcraft-1024.png` | PdfCraft app icon, 1024 px on Apple's icon grid | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Runtime window/Dock icon on macOS (apps/pdfcraft/src/main.rs) | -| `assets/app-icon/pdfcraft.icns` | PdfCraft app icon (macOS .icns) | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | macOS app bundle icon | -| `assets/app-icon/pdfcraft.ico` | PdfCraft app icon (Windows .ico, 16-256 px) | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Embedded in pdfcraft.exe by apps/pdfcraft/build.rs (Explorer, taskbar, Start menu, Alt-Tab) | +| `assets/app-icon/pdfcraft.svg` | PdfCraft app icon (master vector) | PdfCraft contributors (drawn by the project owner in ArtCraft) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Master artwork; every other app-icon file is rendered from it (packaging/icons.sh) | +| `assets/app-icon/pdfcraft-small.svg` | PdfCraft app icon (light vector) | PdfCraft contributors (drawn by the project owner in ArtCraft) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | README header and assets/app-icon/README.md | +| `assets/app-icon/pdfcraft-1024.png` | PdfCraft app icon, 1024 px on Apple's icon grid | PdfCraft contributors (drawn by the project owner in ArtCraft) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Runtime window/Dock icon on macOS (apps/pdfcraft/src/main.rs) | +| `assets/app-icon/pdfcraft.icns` | PdfCraft app icon (macOS .icns) | PdfCraft contributors (drawn by the project owner in ArtCraft) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | macOS app bundle icon | +| `assets/app-icon/pdfcraft.ico` | PdfCraft app icon (Windows .ico, 16-256 px) | PdfCraft contributors (drawn by the project owner in ArtCraft) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Embedded in pdfcraft.exe by apps/pdfcraft/build.rs (Explorer, taskbar, Start menu, Alt-Tab) | | `assets/app-icon/pdfcraft-document.svg` | PdfCraft PDF document icon (master vector) | Lucide Icons and Contributors (document outline); PdfCraft contributors (PDF badge, lettering and composition) | ISC AND (MIT OR Apache-2.0) | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-text.svg); contributor-original composition (assets/app-icon/README.md) | Master artwork for the Windows PDF file type icon; rendered by packaging/icons.sh, without fonts or external images | | `assets/app-icon/pdfcraft-document.ico` | PdfCraft PDF document icon (Windows .ico, 16-256 px) | Lucide Icons and Contributors (document outline); PdfCraft contributors (PDF badge, lettering and composition) | ISC AND (MIT OR Apache-2.0) | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-text.svg); contributor-original composition (assets/app-icon/README.md) | Embedded in pdfcraft.exe as resource 2; the Windows installer uses it for PdfCraft.Document and Applications/pdfcraft.exe PDF file associations | -| `assets/app-icon/hicolor/16x16/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 16 px | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) | -| `assets/app-icon/hicolor/24x24/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 24 px | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) | -| `assets/app-icon/hicolor/32x32/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 32 px | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) | -| `assets/app-icon/hicolor/48x48/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 48 px | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) | -| `assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 64 px | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) | -| `assets/app-icon/hicolor/128x128/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 128 px | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) | -| `assets/app-icon/hicolor/256x256/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 256 px | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop); runtime window icon on Windows and Linux (apps/pdfcraft/src/main.rs) | -| `assets/app-icon/hicolor/512x512/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 512 px | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) | -| `assets/app-icon/hicolor/scalable/apps/ai.storyteller.pdfcraft.svg` | PdfCraft app icon (Linux scalable) | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) | +| `assets/app-icon/hicolor/16x16/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 16 px | PdfCraft contributors (drawn by the project owner in ArtCraft) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) | +| `assets/app-icon/hicolor/24x24/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 24 px | PdfCraft contributors (drawn by the project owner in ArtCraft) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) | +| `assets/app-icon/hicolor/32x32/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 32 px | PdfCraft contributors (drawn by the project owner in ArtCraft) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) | +| `assets/app-icon/hicolor/48x48/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 48 px | PdfCraft contributors (drawn by the project owner in ArtCraft) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) | +| `assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 64 px | PdfCraft contributors (drawn by the project owner in ArtCraft) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) | +| `assets/app-icon/hicolor/128x128/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 128 px | PdfCraft contributors (drawn by the project owner in ArtCraft) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) | +| `assets/app-icon/hicolor/256x256/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 256 px | PdfCraft contributors (drawn by the project owner in ArtCraft) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop); runtime window icon on Windows and Linux (apps/pdfcraft/src/main.rs) | +| `assets/app-icon/hicolor/512x512/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 512 px | PdfCraft contributors (drawn by the project owner in ArtCraft) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) | +| `assets/app-icon/hicolor/scalable/apps/ai.storyteller.pdfcraft.svg` | PdfCraft app icon (Linux scalable) | PdfCraft contributors (drawn by the project owner in ArtCraft) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) | | `docs/images/pdfcraft-bookmarks.png` | PdfCraft screenshot: Bookmarks | PdfCraft contributors (generated by `cargo xtask screenshots`) | MIT OR Apache-2.0 | this repository | README: navigation. Shows only PdfCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts | | `docs/images/pdfcraft-dark.png` | PdfCraft screenshot: Dark theme | PdfCraft contributors (generated by `cargo xtask screenshots`) | MIT OR Apache-2.0 | this repository | README: themes. Shows only PdfCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts | | `docs/images/pdfcraft-find.png` | PdfCraft screenshot: Find text | PdfCraft contributors (generated by `cargo xtask screenshots`) | MIT OR Apache-2.0 | this repository | README: search. Shows only PdfCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts | @@ -269,34 +269,34 @@ Every asset PdfCraft includes, bundles or uses to build its published material, | `docs/images/pdfcraft-tools.png` | PdfCraft screenshot: All tools | PdfCraft contributors (generated by `cargo xtask screenshots`) | MIT OR Apache-2.0 | this repository | README: tool catalogue. Shows PdfCraft UI, Lucide icons, the showcase PDF built from the [[fetched]] OFL fonts, and the ArtCraft mark (Storyteller trademark from docs/brand/, not covered by this licence) | | `docs/images/pdfcraft-twoup.png` | PdfCraft screenshot: Two-up reading | PdfCraft contributors (generated by `cargo xtask screenshots`) | MIT OR Apache-2.0 | this repository | README: layouts. Shows only PdfCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts | | `docs/images/pdfcraft-viewer.png` | PdfCraft screenshot: Viewer with comments | PdfCraft contributors (generated by `cargo xtask screenshots`) | MIT OR Apache-2.0 | this repository | README hero. Shows only PdfCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts | -| `assets/icons/pin.svg` | Lucide icon "pin" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pin.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/move-right.svg` | Lucide icon "move-right" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/move-right.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/reply.svg` | Lucide icon "reply" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/reply.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/thumbs-up.svg` | Lucide icon "thumbs-up" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/thumbs-up.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/thumbs-down.svg` | Lucide icon "thumbs-down" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/thumbs-down.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/message-square-plus.svg` | Lucide icon "message-square-plus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-square-plus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/circle-x.svg` | Lucide icon "circle-x" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-x.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/pin.svg` | Lucide icon "pin" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pin.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/move-right.svg` | Lucide icon "move-right" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/move-right.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/reply.svg` | Lucide icon "reply" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/reply.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/thumbs-up.svg` | Lucide icon "thumbs-up" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/thumbs-up.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/thumbs-down.svg` | Lucide icon "thumbs-down" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/thumbs-down.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/message-square-plus.svg` | Lucide icon "message-square-plus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-square-plus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/circle-x.svg` | Lucide icon "circle-x" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-x.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `crates/ui-egui/tests/data/form.pdf` | One-page AcroForm test fixture (text, check box, radio group, combo box) | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: hand-written PDF objects, no third-party content | Form-filling UI tests (crates/ui-egui/tests/it/forms.rs, commands.rs) | -| `assets/icons/pentagon.svg` | Lucide icon "pentagon" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pentagon.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/message-square-quote.svg` | Lucide icon "message-square-quote" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-square-quote.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/pentagon.svg` | Lucide icon "pentagon" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pentagon.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/message-square-quote.svg` | Lucide icon "message-square-quote" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-square-quote.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `crates/sign/tests/data/openssl-signed.pdf` | One-page PDF signed with OpenSSL CMS (adbe.pkcs7.detached) by the RSA test key | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: hand-written PDF objects; signature made with `openssl cms -sign` over its byte ranges (see crates/sign/tests/data/README.md) | Signature validation tests (crates/sign/tests/it/pdf.rs): a signature PdfCraft did not make | -| `assets/icons/clipboard-paste.svg` | Lucide icon "clipboard-paste" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/clipboard-paste.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/clipboard-paste.svg` | Lucide icon "clipboard-paste" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/clipboard-paste.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/fonts/DancingScript.ttf` | Dancing Script (variable) | The Dancing Script Project Authors (Pablo Impallari) | OFL-1.1 | https://github.com/google/fonts/blob/9710da1eacb3be272583c3224dcb70f9da6eadbb/ofl/dancingscript/DancingScript%5Bwght%5D.ttf | Fill & Sign typed signatures and initials: the typed name's glyph outlines, drawn as filled paths (the font file itself is not embedded in PDFs) | | `packaging/macos/dmg/background.svg` | PdfCraft macOS DMG window background | @XusBadia | MIT OR Apache-2.0 | Original work for this repository, derived from the PdfCraft app icon (assets/app-icon/pdfcraft.svg, referenced via an SVG image element, not copied) | Finder window of the macOS DMG; rendered to background.tiff by packaging/macos/dmg/generate.py | | `packaging/macos/dmg/background.tiff` | PdfCraft macOS DMG window background (rendered) | @XusBadia | MIT OR Apache-2.0 | Rendered from packaging/macos/dmg/background.svg by packaging/macos/dmg/generate.py | 1x + 2x HiDPI 16-colour palette TIFF copied into the macOS DMG by packaging/macos/package.sh; includes the app icon and the free LittleCMS built-in sRGB profile | | `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/it/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/vi.tsv` | Vietnamese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `assets/icons/monitor.svg` | Lucide icon "monitor" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | 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/vi.tsv` | Vietnamese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n.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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Slovene UI strings, compiled in by crates/ui-egui/src/i18n.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 | -| `crates/ui-egui/src/i18n/nb.tsv` | Norwegian Bokmål 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; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Norwegian Bokmål UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `assets/icons/superscript.svg` | Lucide icon "superscript" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/superscript.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/subscript.svg` | Lucide icon "subscript" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/subscript.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/pilcrow.svg` | Lucide icon "pilcrow" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pilcrow.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/move-horizontal.svg` | Lucide icon "move-horizontal" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/move-horizontal.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `crates/ui-egui/src/i18n/sq.tsv` | Albanian 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; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-appearance labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Albanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/th.tsv` | Thai 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; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Thai UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/ca.tsv` | Catalan interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, written from their meaning; no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Catalan UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/nb.tsv` | Norwegian Bokmål 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; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Norwegian Bokmål UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `assets/icons/superscript.svg` | Lucide icon "superscript" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/superscript.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/subscript.svg` | Lucide icon "subscript" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/subscript.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/pilcrow.svg` | Lucide icon "pilcrow" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pilcrow.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/move-horizontal.svg` | Lucide icon "move-horizontal" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/move-horizontal.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `crates/ui-egui/src/i18n/sq.tsv` | Albanian 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; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-appearance labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Albanian UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/th.tsv` | Thai 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; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Thai UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/ca.tsv` | Catalan interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, written from their meaning; no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Catalan UI strings, compiled in by crates/ui-egui/src/i18n.rs | ## Compiled in through dependencies (6) @@ -335,8 +335,8 @@ Fonts are fetched by `cargo xtask demo-pdf` into `target/demo-fonts/`, OCR model | `NotoColorEmoji-Regular.ttf` | Noto Color Emoji | Google Inc. | OFL-1.1 | https://github.com/google/fonts/tree/9710da1eacb3be272583c3224dcb70f9da6eadbb/ofl/notocoloremoji | Showcase PDF (cargo xtask demo-pdf) | | `Inter[opsz,wght].ttf` | Inter (variable) | The Inter Project Authors (Rasmus Andersson) | OFL-1.1 | https://github.com/google/fonts/tree/9710da1eacb3be272583c3224dcb70f9da6eadbb/ofl/inter | Showcase PDF (cargo xtask demo-pdf) | | `Inter-Italic[opsz,wght].ttf` | Inter Italic (variable) | The Inter Project Authors (Rasmus Andersson) | OFL-1.1 | https://github.com/google/fonts/tree/9710da1eacb3be272583c3224dcb70f9da6eadbb/ofl/inter | Showcase PDF (cargo xtask demo-pdf) | -| `text-detection.rten` | Ocrs text detection model | Robert Knight (ocrs-models; trained on HierText, CC-BY-SA-4.0) | CC-BY-SA-4.0 | https://github.com/robertknight/ocrs-models (the models ocrs downloads; model card: https://huggingface.co/robertknight/ocrs, cc-by-sa-4.0) | Scan & OCR: finds words on page images (cargo xtask models) | -| `text-recognition.rten` | Ocrs text recognition model | Robert Knight (ocrs-models; trained on HierText, CC-BY-SA-4.0) | CC-BY-SA-4.0 | https://github.com/robertknight/ocrs-models (the models ocrs downloads; model card: https://huggingface.co/robertknight/ocrs, cc-by-sa-4.0) | Scan & OCR: reads the words it finds (cargo xtask models) | +| `text-detection.onnx` | Ocrs text detection model | Robert Knight (ocrs-models; trained on HierText, CC-BY-SA-4.0) | CC-BY-SA-4.0 | https://github.com/robertknight/ocrs/blob/0c826441362a701c55560fefc17bf356b6d1037e/ocrs/examples/download-models.sh (author-published ONNX models; model card: https://huggingface.co/robertknight/ocrs/blob/df0edd170279ab971b53e094c627255a87e1a503/README.md, cc-by-sa-4.0) | Scan & OCR: finds words on page images (cargo xtask models) | +| `text-recognition.onnx` | Ocrs text recognition model | Robert Knight (ocrs-models; trained on HierText, CC-BY-SA-4.0) | CC-BY-SA-4.0 | https://github.com/robertknight/ocrs/blob/0c826441362a701c55560fefc17bf356b6d1037e/ocrs/examples/download-models.sh (author-published ONNX models; model card: https://huggingface.co/robertknight/ocrs/blob/df0edd170279ab971b53e094c627255a87e1a503/README.md, cc-by-sa-4.0) | Scan & OCR: reads the words it finds (cargo xtask models) | ## Optional build inputs (1) diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 8b2186d5b4..76f4cf40a8 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -6,7 +6,7 @@ source = "Contributor-original: clean-room translations of PdfCraft English UI l 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" +usage = "Greek UI strings, compiled in by crates/ui-egui/src/i18n.rs" # 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 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI 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" +usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/zh-hant.tsv" @@ -40,7 +40,7 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI 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" +usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/cs.tsv" @@ -50,7 +50,7 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI 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" +usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/pt-br.tsv" @@ -60,7 +60,7 @@ source = "Clean-room translations of PdfCraft's English UI labels, no proprietar 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" +usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/es.tsv" @@ -70,7 +70,7 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI 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" +usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/fr.tsv" @@ -80,7 +80,7 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI 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" +usage = "French UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/de.tsv" @@ -90,7 +90,7 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI 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" +usage = "German UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/lo.tsv" @@ -100,7 +100,7 @@ source = "Contributor-original: clean-room translations of PdfCraft English UI l licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Lao UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Lao UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/te.tsv" @@ -110,7 +110,7 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI 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" +usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/it.tsv" @@ -120,7 +120,7 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI 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" +usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/pl.tsv" @@ -130,7 +130,7 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI 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" +usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/ru.tsv" @@ -140,7 +140,7 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI 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" +usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/hu.tsv" @@ -150,7 +150,7 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI 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" +usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/bg.tsv" @@ -160,7 +160,7 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI 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" +usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/kk.tsv" @@ -170,7 +170,7 @@ source = "Contributor-original: clean-room translations of the English UI labels 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" +usage = "Kazakh UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/ja.tsv" @@ -180,7 +180,7 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI 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" +usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/uk.tsv" @@ -190,7 +190,7 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI 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" +usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/ar.tsv" @@ -200,7 +200,7 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI 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" +usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/ckb.tsv" @@ -210,7 +210,7 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Central Kurdish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Central Kurdish UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/fa.tsv" @@ -220,7 +220,7 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Persian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Persian UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/tr.tsv" @@ -230,7 +230,7 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI 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" +usage = "Turkish UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/lt.tsv" @@ -240,7 +240,7 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI 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" +usage = "Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/hi.tsv" @@ -250,7 +250,7 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Hindi UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Hindi UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/id.tsv" title = "Indonesian interface translation catalog" @@ -259,7 +259,7 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Indonesian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Indonesian UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/ko.tsv" title = "Korean interface translation catalog" @@ -268,7 +268,7 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Korean UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Korean UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/ro.tsv" @@ -278,7 +278,7 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Romanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Romanian UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] @@ -289,14 +289,14 @@ source = "Contributor-original: AI-assisted (Claude Code) clean-room translation licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Finnish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Finnish UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "assets/icons/accessibility.svg" title = "Lucide icon \"accessibility\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/accessibility.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -307,7 +307,7 @@ path = "assets/icons/align-left.svg" title = "Lucide icon \"align-left\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-left.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -318,7 +318,7 @@ path = "assets/icons/align-center.svg" title = "Lucide icon \"align-center\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-center.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -329,7 +329,7 @@ path = "assets/icons/square-terminal.svg" title = "Lucide icon \"square-terminal\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/square-terminal.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -340,7 +340,7 @@ path = "assets/icons/file-code.svg" title = "Lucide icon \"file-code\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-code.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -351,7 +351,7 @@ path = "assets/icons/file-spreadsheet.svg" title = "Lucide icon \"file-spreadsheet\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-spreadsheet.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -362,7 +362,7 @@ path = "assets/icons/align-justify.svg" title = "Lucide icon \"align-justify\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-justify.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -373,7 +373,7 @@ path = "assets/icons/align-right.svg" title = "Lucide icon \"align-right\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-right.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -384,7 +384,7 @@ path = "assets/icons/columns-3.svg" title = "Lucide icon \"columns-3\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/columns-3.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -395,7 +395,7 @@ path = "assets/icons/rows-3.svg" title = "Lucide icon \"rows-3\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/rows-3.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -406,7 +406,7 @@ path = "assets/icons/flip-horizontal-2.svg" title = "Lucide icon \"flip-horizontal-2\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/flip-horizontal-2.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -417,7 +417,7 @@ path = "assets/icons/flip-vertical-2.svg" title = "Lucide icon \"flip-vertical-2\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/flip-vertical-2.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -428,7 +428,7 @@ path = "assets/icons/app-window.svg" title = "Lucide icon \"app-window\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/app-window.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -439,7 +439,7 @@ path = "assets/icons/arrow-left-right.svg" title = "Lucide icon \"arrow-left-right\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/arrow-left-right.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -450,7 +450,7 @@ path = "assets/icons/arrow-up-down.svg" title = "Lucide icon \"arrow-up-down\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/arrow-up-down.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -461,7 +461,7 @@ path = "assets/icons/arrow-up-right.svg" title = "Lucide icon \"arrow-up-right\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/arrow-up-right.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -472,7 +472,7 @@ path = "assets/icons/badge-check.svg" title = "Lucide icon \"badge-check\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/badge-check.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -483,7 +483,7 @@ path = "assets/icons/bandage.svg" title = "Lucide icon \"bandage\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/bandage.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -494,7 +494,7 @@ path = "assets/icons/bell.svg" title = "Lucide icon \"bell\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/bell.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -505,7 +505,7 @@ path = "assets/icons/blend.svg" title = "Lucide icon \"blend\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/blend.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -516,7 +516,7 @@ path = "assets/icons/bold.svg" title = "Lucide icon \"bold\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/bold.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -527,7 +527,7 @@ path = "assets/icons/book-open.svg" title = "Lucide icon \"book-open\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/book-open.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -538,7 +538,7 @@ path = "assets/icons/bookmark.svg" title = "Lucide icon \"bookmark\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/bookmark.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -549,7 +549,7 @@ path = "assets/icons/bookmark-plus.svg" title = "Lucide icon \"bookmark-plus\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/bookmark-plus.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -560,7 +560,7 @@ path = "assets/icons/braces.svg" title = "Lucide icon \"braces\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/braces.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -571,7 +571,7 @@ path = "assets/icons/brush-cleaning.svg" title = "Lucide icon \"brush-cleaning\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/brush-cleaning.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -582,7 +582,7 @@ path = "assets/icons/brush.svg" title = "Lucide icon \"brush\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/brush.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -593,7 +593,7 @@ path = "assets/icons/camera.svg" title = "Lucide icon \"camera\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/camera.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -604,7 +604,7 @@ path = "assets/icons/check-circle-2.svg" title = "Lucide icon \"check-circle-2\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/check-circle-2.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -615,7 +615,7 @@ path = "assets/icons/check.svg" title = "Lucide icon \"check\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/check.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -626,7 +626,7 @@ path = "assets/icons/chevron-down.svg" title = "Lucide icon \"chevron-down\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/chevron-down.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -637,7 +637,7 @@ path = "assets/icons/chevron-left.svg" title = "Lucide icon \"chevron-left\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/chevron-left.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -648,7 +648,7 @@ path = "assets/icons/chevron-right.svg" title = "Lucide icon \"chevron-right\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/chevron-right.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -659,7 +659,7 @@ path = "assets/icons/chevron-up.svg" title = "Lucide icon \"chevron-up\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/chevron-up.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -670,7 +670,7 @@ path = "assets/icons/chevrons-left.svg" title = "Lucide icon \"chevrons-left\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/chevrons-left.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -681,7 +681,7 @@ path = "assets/icons/chevrons-right.svg" title = "Lucide icon \"chevrons-right\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/chevrons-right.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -692,7 +692,7 @@ path = "assets/icons/circle-check.svg" title = "Lucide icon \"circle-check\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-check.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -703,7 +703,7 @@ path = "assets/icons/circle-dashed.svg" title = "Lucide icon \"circle-dashed\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-dashed.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -714,7 +714,7 @@ path = "assets/icons/circle-dot.svg" title = "Lucide icon \"circle-dot\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-dot.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -725,7 +725,7 @@ path = "assets/icons/circle-help.svg" title = "Lucide icon \"circle-help\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-help.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -736,7 +736,7 @@ path = "assets/icons/circle.svg" title = "Lucide icon \"circle\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -747,7 +747,7 @@ path = "assets/icons/clock-3.svg" title = "Lucide icon \"clock-3\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/clock-3.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -758,7 +758,7 @@ path = "assets/icons/clock.svg" title = "Lucide icon \"clock\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/clock.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -769,7 +769,7 @@ path = "assets/icons/cloud-upload.svg" title = "Lucide icon \"cloud-upload\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/cloud-upload.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -780,7 +780,7 @@ path = "assets/icons/cloud.svg" title = "Lucide icon \"cloud\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/cloud.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -791,7 +791,7 @@ path = "assets/icons/code-xml.svg" title = "Lucide icon \"code-xml\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/code-xml.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -802,7 +802,7 @@ path = "assets/icons/columns-2.svg" title = "Lucide icon \"columns-2\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/columns-2.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -813,7 +813,7 @@ path = "assets/icons/compass.svg" title = "Lucide icon \"compass\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/compass.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -824,7 +824,7 @@ path = "assets/icons/contrast.svg" title = "Lucide icon \"contrast\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/contrast.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -835,7 +835,7 @@ path = "assets/icons/copy-plus.svg" title = "Lucide icon \"copy-plus\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/copy-plus.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -846,7 +846,7 @@ path = "assets/icons/copy.svg" title = "Lucide icon \"copy\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/copy.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -857,7 +857,7 @@ path = "assets/icons/crop.svg" title = "Lucide icon \"crop\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/crop.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -868,7 +868,7 @@ path = "assets/icons/droplet.svg" title = "Lucide icon \"droplet\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/droplet.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -879,7 +879,7 @@ path = "assets/icons/ellipsis-vertical.svg" title = "Lucide icon \"ellipsis-vertical\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/ellipsis-vertical.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -890,7 +890,7 @@ path = "assets/icons/ellipsis.svg" title = "Lucide icon \"ellipsis\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/ellipsis.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -901,7 +901,7 @@ path = "assets/icons/eraser.svg" title = "Lucide icon \"eraser\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/eraser.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -912,7 +912,7 @@ path = "assets/icons/external-link.svg" title = "Lucide icon \"external-link\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/external-link.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -923,7 +923,7 @@ path = "assets/icons/eye-off.svg" title = "Lucide icon \"eye-off\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/eye-off.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -934,7 +934,7 @@ path = "assets/icons/eye.svg" title = "Lucide icon \"eye\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/eye.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -945,7 +945,7 @@ path = "assets/icons/file-check.svg" title = "Lucide icon \"file-check\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-check.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -956,7 +956,7 @@ path = "assets/icons/file-down.svg" title = "Lucide icon \"file-down\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-down.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -967,7 +967,7 @@ path = "assets/icons/file-input.svg" title = "Lucide icon \"file-input\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-input.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -978,7 +978,7 @@ path = "assets/icons/file-lock-2.svg" title = "Lucide icon \"file-lock-2\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-lock-2.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -989,7 +989,7 @@ path = "assets/icons/file-minus.svg" title = "Lucide icon \"file-minus\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-minus.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1000,7 +1000,7 @@ path = "assets/icons/file-output.svg" title = "Lucide icon \"file-output\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-output.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1011,7 +1011,7 @@ path = "assets/icons/file-pen-line.svg" title = "Lucide icon \"file-pen-line\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-pen-line.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1022,7 +1022,7 @@ path = "assets/icons/file-plus-2.svg" title = "Lucide icon \"file-plus-2\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-plus-2.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1033,7 +1033,7 @@ path = "assets/icons/file-plus.svg" title = "Lucide icon \"file-plus\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-plus.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1044,7 +1044,7 @@ path = "assets/icons/file-scan.svg" title = "Lucide icon \"file-scan\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-scan.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1055,7 +1055,7 @@ path = "assets/icons/file-search.svg" title = "Lucide icon \"file-search\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-search.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1066,7 +1066,7 @@ path = "assets/icons/file-symlink.svg" title = "Lucide icon \"file-symlink\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-symlink.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1077,7 +1077,7 @@ path = "assets/icons/file-text.svg" title = "Lucide icon \"file-text\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-text.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1088,7 +1088,7 @@ path = "assets/icons/file-x.svg" title = "Lucide icon \"file-x\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-x.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1099,7 +1099,7 @@ path = "assets/icons/files.svg" title = "Lucide icon \"files\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/files.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1110,7 +1110,7 @@ path = "assets/icons/film.svg" title = "Lucide icon \"film\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/film.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1121,7 +1121,7 @@ path = "assets/icons/filter.svg" title = "Lucide icon \"filter\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/filter.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1132,7 +1132,7 @@ path = "assets/icons/folder-open.svg" title = "Lucide icon \"folder-open\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/folder-open.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1143,7 +1143,7 @@ path = "assets/icons/folder-plus.svg" title = "Lucide icon \"folder-plus\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/folder-plus.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1154,7 +1154,7 @@ path = "assets/icons/folder.svg" title = "Lucide icon \"folder\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/folder.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1165,7 +1165,7 @@ path = "assets/icons/gauge.svg" title = "Lucide icon \"gauge\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/gauge.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1176,7 +1176,7 @@ path = "assets/icons/git-compare.svg" title = "Lucide icon \"git-compare\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/git-compare.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1187,7 +1187,7 @@ path = "assets/icons/globe.svg" title = "Lucide icon \"globe\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/globe.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1198,7 +1198,7 @@ path = "assets/icons/grid-3x3.svg" title = "Lucide icon \"grid-3x3\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/grid-3x3.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1209,7 +1209,7 @@ path = "assets/icons/hand.svg" title = "Lucide icon \"hand\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/hand.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1220,7 +1220,7 @@ path = "assets/icons/hash.svg" title = "Lucide icon \"hash\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/hash.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1231,7 +1231,7 @@ path = "assets/icons/heading.svg" title = "Lucide icon \"heading\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/heading.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1242,7 +1242,7 @@ path = "assets/icons/highlighter.svg" title = "Lucide icon \"highlighter\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/highlighter.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1253,7 +1253,7 @@ path = "assets/icons/house.svg" title = "Lucide icon \"house\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/house.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1264,7 +1264,7 @@ path = "assets/icons/image-plus.svg" title = "Lucide icon \"image-plus\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/image-plus.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1275,7 +1275,7 @@ path = "assets/icons/image.svg" title = "Lucide icon \"image\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/image.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1286,7 +1286,7 @@ path = "assets/icons/info.svg" title = "Lucide icon \"info\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/info.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1297,7 +1297,7 @@ path = "assets/icons/italic.svg" title = "Lucide icon \"italic\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/italic.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1308,7 +1308,7 @@ path = "assets/icons/languages.svg" title = "Lucide icon \"languages\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/languages.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1319,7 +1319,7 @@ path = "assets/icons/lasso.svg" title = "Lucide icon \"lasso\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/lasso.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1330,7 +1330,7 @@ path = "assets/icons/layers.svg" title = "Lucide icon \"layers\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/layers.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1341,7 +1341,7 @@ path = "assets/icons/layout-grid.svg" title = "Lucide icon \"layout-grid\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/layout-grid.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1352,7 +1352,7 @@ path = "assets/icons/link-2.svg" title = "Lucide icon \"link-2\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/link-2.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1363,7 +1363,7 @@ path = "assets/icons/link.svg" title = "Lucide icon \"link\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/link.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1374,7 +1374,7 @@ path = "assets/icons/list-checks.svg" title = "Lucide icon \"list-checks\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/list-checks.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1385,7 +1385,7 @@ path = "assets/icons/list-ordered.svg" title = "Lucide icon \"list-ordered\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/list-ordered.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1396,7 +1396,7 @@ path = "assets/icons/list.svg" title = "Lucide icon \"list\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/list.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1407,7 +1407,7 @@ path = "assets/icons/lock-open.svg" title = "Lucide icon \"lock-open\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/lock-open.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1418,7 +1418,7 @@ path = "assets/icons/lock.svg" title = "Lucide icon \"lock\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/lock.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1429,7 +1429,7 @@ path = "assets/icons/maximize-2.svg" title = "Lucide icon \"maximize-2\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/maximize-2.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1440,7 +1440,7 @@ path = "assets/icons/maximize.svg" title = "Lucide icon \"maximize\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/maximize.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1451,7 +1451,7 @@ path = "assets/icons/message-circle-reply.svg" title = "Lucide icon \"message-circle-reply\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-circle-reply.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1462,7 +1462,7 @@ path = "assets/icons/message-square-text.svg" title = "Lucide icon \"message-square-text\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-square-text.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1473,7 +1473,7 @@ path = "assets/icons/message-square.svg" title = "Lucide icon \"message-square\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-square.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1484,7 +1484,7 @@ path = "assets/icons/messages-square.svg" title = "Lucide icon \"messages-square\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/messages-square.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1495,7 +1495,7 @@ path = "assets/icons/mic.svg" title = "Lucide icon \"mic\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/mic.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1506,7 +1506,7 @@ path = "assets/icons/minimize.svg" title = "Lucide icon \"minimize\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/minimize.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1517,7 +1517,7 @@ path = "assets/icons/minimize-2.svg" title = "Lucide icon \"minimize-2\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/minimize-2.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1528,7 +1528,7 @@ path = "assets/icons/minus.svg" title = "Lucide icon \"minus\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/minus.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1539,7 +1539,7 @@ path = "assets/icons/moon.svg" title = "Lucide icon \"moon\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/moon.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1550,7 +1550,7 @@ path = "assets/icons/mouse-pointer-2.svg" title = "Lucide icon \"mouse-pointer-2\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/mouse-pointer-2.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1561,7 +1561,7 @@ path = "assets/icons/move.svg" title = "Lucide icon \"move\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/move.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1572,7 +1572,7 @@ path = "assets/icons/navigation.svg" title = "Lucide icon \"navigation\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/navigation.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1583,7 +1583,7 @@ path = "assets/icons/paint-bucket.svg" title = "Lucide icon \"paint-bucket\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/paint-bucket.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1594,7 +1594,7 @@ path = "assets/icons/palette.svg" title = "Lucide icon \"palette\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/palette.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1605,7 +1605,7 @@ path = "assets/icons/panel-left.svg" title = "Lucide icon \"panel-left\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/panel-left.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1616,7 +1616,7 @@ path = "assets/icons/panel-right.svg" title = "Lucide icon \"panel-right\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/panel-right.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1627,7 +1627,7 @@ path = "assets/icons/panels-top-left.svg" title = "Lucide icon \"panels-top-left\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/panels-top-left.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1638,7 +1638,7 @@ path = "assets/icons/paperclip.svg" title = "Lucide icon \"paperclip\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/paperclip.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1649,7 +1649,7 @@ path = "assets/icons/pen-line.svg" title = "Lucide icon \"pen-line\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pen-line.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1660,7 +1660,7 @@ path = "assets/icons/pen-tool.svg" title = "Lucide icon \"pen-tool\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pen-tool.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1671,7 +1671,7 @@ path = "assets/icons/pencil.svg" title = "Lucide icon \"pencil\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pencil.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1682,7 +1682,7 @@ path = "assets/icons/pipette.svg" title = "Lucide icon \"pipette\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pipette.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1693,7 +1693,7 @@ path = "assets/icons/plus.svg" title = "Lucide icon \"plus\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/plus.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1704,7 +1704,7 @@ path = "assets/icons/presentation.svg" title = "Lucide icon \"presentation\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/presentation.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1715,7 +1715,7 @@ path = "assets/icons/printer.svg" title = "Lucide icon \"printer\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/printer.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1726,7 +1726,7 @@ path = "assets/icons/rectangle-horizontal.svg" title = "Lucide icon \"rectangle-horizontal\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/rectangle-horizontal.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1737,7 +1737,7 @@ path = "assets/icons/redo-2.svg" title = "Lucide icon \"redo-2\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/redo-2.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1748,7 +1748,7 @@ path = "assets/icons/replace.svg" title = "Lucide icon \"replace\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/replace.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1759,7 +1759,7 @@ path = "assets/icons/rotate-ccw.svg" title = "Lucide icon \"rotate-ccw\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/rotate-ccw.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1770,7 +1770,7 @@ path = "assets/icons/rotate-cw.svg" title = "Lucide icon \"rotate-cw\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/rotate-cw.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1781,7 +1781,7 @@ path = "assets/icons/ruler.svg" title = "Lucide icon \"ruler\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/ruler.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1792,7 +1792,7 @@ path = "assets/icons/save.svg" title = "Lucide icon \"save\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/save.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1803,7 +1803,7 @@ path = "assets/icons/scan-text.svg" title = "Lucide icon \"scan-text\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/scan-text.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1814,7 +1814,7 @@ path = "assets/icons/scan.svg" title = "Lucide icon \"scan\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/scan.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1825,7 +1825,7 @@ path = "assets/icons/scissors.svg" title = "Lucide icon \"scissors\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/scissors.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1836,7 +1836,7 @@ path = "assets/icons/search.svg" title = "Lucide icon \"search\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/search.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1847,7 +1847,7 @@ path = "assets/icons/send.svg" title = "Lucide icon \"send\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/send.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1858,7 +1858,7 @@ path = "assets/icons/settings-2.svg" title = "Lucide icon \"settings-2\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/settings-2.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1869,7 +1869,7 @@ path = "assets/icons/settings.svg" title = "Lucide icon \"settings\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/settings.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1880,7 +1880,7 @@ path = "assets/icons/share-2.svg" title = "Lucide icon \"share-2\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/share-2.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1891,7 +1891,7 @@ path = "assets/icons/shield-check.svg" title = "Lucide icon \"shield-check\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/shield-check.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1902,7 +1902,7 @@ path = "assets/icons/signature.svg" title = "Lucide icon \"signature\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/signature.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1913,7 +1913,7 @@ path = "assets/icons/sliders-horizontal.svg" title = "Lucide icon \"sliders-horizontal\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/sliders-horizontal.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1924,7 +1924,7 @@ path = "assets/icons/sparkles.svg" title = "Lucide icon \"sparkles\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/sparkles.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1935,7 +1935,7 @@ path = "assets/icons/spline.svg" title = "Lucide icon \"spline\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/spline.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1946,7 +1946,7 @@ path = "assets/icons/square-dashed-mouse-pointer.svg" title = "Lucide icon \"square-dashed-mouse-pointer\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/square-dashed-mouse-pointer.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1957,7 +1957,7 @@ path = "assets/icons/square-dashed.svg" title = "Lucide icon \"square-dashed\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/square-dashed.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1968,7 +1968,7 @@ path = "assets/icons/square.svg" title = "Lucide icon \"square\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/square.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1979,7 +1979,7 @@ path = "assets/icons/squares-subtract.svg" title = "Lucide icon \"squares-subtract\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/squares-subtract.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1990,7 +1990,7 @@ path = "assets/icons/stamp.svg" title = "Lucide icon \"stamp\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/stamp.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -2001,7 +2001,7 @@ path = "assets/icons/star.svg" title = "Lucide icon \"star\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/star.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -2012,7 +2012,7 @@ path = "assets/icons/sticky-note.svg" title = "Lucide icon \"sticky-note\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/sticky-note.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -2023,7 +2023,7 @@ path = "assets/icons/strikethrough.svg" title = "Lucide icon \"strikethrough\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/strikethrough.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -2034,7 +2034,7 @@ path = "assets/icons/sun.svg" title = "Lucide icon \"sun\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/sun.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -2045,7 +2045,7 @@ path = "assets/icons/swatch-book.svg" title = "Lucide icon \"swatch-book\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/swatch-book.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -2056,7 +2056,7 @@ path = "assets/icons/tag.svg" title = "Lucide icon \"tag\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/tag.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -2067,7 +2067,7 @@ path = "assets/icons/text-cursor-input.svg" title = "Lucide icon \"text-cursor-input\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/text-cursor-input.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -2078,7 +2078,7 @@ path = "assets/icons/text-select.svg" title = "Lucide icon \"text-select\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/text-select.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -2089,7 +2089,7 @@ path = "assets/icons/trash-2.svg" title = "Lucide icon \"trash-2\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/trash-2.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -2100,7 +2100,7 @@ path = "assets/icons/trash.svg" title = "Lucide icon \"trash\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/trash.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -2111,7 +2111,7 @@ path = "assets/icons/triangle-alert.svg" title = "Lucide icon \"triangle-alert\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/triangle-alert.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -2122,7 +2122,7 @@ path = "assets/icons/type.svg" title = "Lucide icon \"type\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/type.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -2133,7 +2133,7 @@ path = "assets/icons/underline.svg" title = "Lucide icon \"underline\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/underline.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -2144,7 +2144,7 @@ path = "assets/icons/undo-2.svg" title = "Lucide icon \"undo-2\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/undo-2.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -2155,7 +2155,7 @@ path = "assets/icons/user-round.svg" title = "Lucide icon \"user-round\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/user-round.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -2166,7 +2166,7 @@ path = "assets/icons/wand-sparkles.svg" title = "Lucide icon \"wand-sparkles\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/wand-sparkles.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -2177,7 +2177,7 @@ path = "assets/icons/wrench.svg" title = "Lucide icon \"wrench\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/wrench.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -2188,7 +2188,7 @@ path = "assets/icons/x.svg" title = "Lucide icon \"x\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/x.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -2199,7 +2199,7 @@ path = "assets/icons/zoom-in.svg" title = "Lucide icon \"zoom-in\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/zoom-in.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -2210,7 +2210,7 @@ path = "assets/icons/zoom-out.svg" title = "Lucide icon \"zoom-out\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/zoom-out.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -2873,57 +2873,57 @@ sha256 = "f87af81b6fa411da8820eed2bfdcc54c0ee9cd453b4e868c64f56c69db142d0e" [[asset]] path = "assets/app-icon/pdfcraft.svg" title = "PdfCraft app icon (master vector)" -author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)" +author = "PdfCraft contributors (drawn by the project owner in ArtCraft)" source = "Contributor-original (see assets/app-icon/README.md)" licence = "MIT OR Apache-2.0" licence_file = "assets/app-icon/LICENSE.txt" kind = "icon" usage = "Master artwork; every other app-icon file is rendered from it (packaging/icons.sh)" -sha256 = "52b384a10a2a7fdd3145d9822972cfd568ef0cb7312549bd4399addd89d45a6d" +sha256 = "27745c021830f82c033b0f15f2d8d9f8e099138743297887ff7e2c9e7ed069da" [[asset]] path = "assets/app-icon/pdfcraft-small.svg" title = "PdfCraft app icon (light vector)" -author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)" +author = "PdfCraft contributors (drawn by the project owner in ArtCraft)" source = "Contributor-original (see assets/app-icon/README.md)" licence = "MIT OR Apache-2.0" licence_file = "assets/app-icon/LICENSE.txt" kind = "icon" usage = "README header and assets/app-icon/README.md" -sha256 = "b036304c11ef5dcad36636d06ca0bf331b51e47650d25d3c916a0e1eabf544c7" +sha256 = "b524df1a583f8034a5a23836955e4fb423e0149429d695e80d95e5056f408678" [[asset]] path = "assets/app-icon/pdfcraft-1024.png" title = "PdfCraft app icon, 1024 px on Apple's icon grid" -author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)" +author = "PdfCraft contributors (drawn by the project owner in ArtCraft)" source = "Contributor-original (see assets/app-icon/README.md)" licence = "MIT OR Apache-2.0" licence_file = "assets/app-icon/LICENSE.txt" kind = "icon" usage = "Runtime window/Dock icon on macOS (apps/pdfcraft/src/main.rs)" -sha256 = "da577d3f21fbcf5c739067787a9e683be2b064d76813b5d5fec9074a7a106538" +sha256 = "68ac6730b8990d07503351d32eacea81e9ec171a7a6f09daf52d6555d8245c8a" [[asset]] path = "assets/app-icon/pdfcraft.icns" title = "PdfCraft app icon (macOS .icns)" -author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)" +author = "PdfCraft contributors (drawn by the project owner in ArtCraft)" source = "Contributor-original (see assets/app-icon/README.md)" licence = "MIT OR Apache-2.0" licence_file = "assets/app-icon/LICENSE.txt" kind = "icon" usage = "macOS app bundle icon" -sha256 = "2425148222873de241d267aba667a8b234ecdd0149f2ca8bb6e79443b743b1e8" +sha256 = "fca7e7a0e34cc5c5ecae5bca97ee896aac68c4a34b088bc3a0b3271484f23974" [[asset]] path = "assets/app-icon/pdfcraft.ico" title = "PdfCraft app icon (Windows .ico, 16-256 px)" -author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)" +author = "PdfCraft contributors (drawn by the project owner in ArtCraft)" source = "Contributor-original (see assets/app-icon/README.md)" licence = "MIT OR Apache-2.0" licence_file = "assets/app-icon/LICENSE.txt" kind = "icon" usage = "Embedded in pdfcraft.exe by apps/pdfcraft/build.rs (Explorer, taskbar, Start menu, Alt-Tab)" -sha256 = "56daaaae9ae5389794909570885809adf5e1d9183bef2d9669ed2f9bba3ed6f5" +sha256 = "709271d66e07710a3f785ea21b0c85c5228eaf8bebe5058bc0f71f9ef20749eb" [[asset]] path = "assets/app-icon/pdfcraft-document.svg" @@ -2934,7 +2934,7 @@ licence = "ISC AND (MIT OR Apache-2.0)" licence_file = "assets/app-icon/LICENSE-document.txt" kind = "icon" usage = "Master artwork for the Windows PDF file type icon; rendered by packaging/icons.sh, without fonts or external images" -sha256 = "8da8278c2b89362be0a960879d453ff879d7544d6d58e47d5389574e8dfff77b" +sha256 = "c129f1b538ce16e3c3fa0d89d3301038c51249ba5d61bfd98c46e69cfdf5c316" [[asset]] path = "assets/app-icon/pdfcraft-document.ico" @@ -2945,106 +2945,106 @@ licence = "ISC AND (MIT OR Apache-2.0)" licence_file = "assets/app-icon/LICENSE-document.txt" kind = "icon" usage = "Embedded in pdfcraft.exe as resource 2; the Windows installer uses it for PdfCraft.Document and Applications/pdfcraft.exe PDF file associations" -sha256 = "ba3f9128b368fc6d17d376cf62dbdbc22455451c5c8c4779210d7a2a7409b9e6" +sha256 = "910be5e0ecdc94ce00368077ca1bef449bd5d62a75516feeb37884296c6fac4d" [[asset]] path = "assets/app-icon/hicolor/16x16/apps/ai.storyteller.pdfcraft.png" title = "PdfCraft app icon, 16 px" -author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)" +author = "PdfCraft contributors (drawn by the project owner in ArtCraft)" source = "Contributor-original (see assets/app-icon/README.md)" licence = "MIT OR Apache-2.0" licence_file = "assets/app-icon/LICENSE.txt" kind = "icon" usage = "Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop)" -sha256 = "9b77095cbe75dcba6b74a1fdcd4064812850efaf7a12edebefc9ded5eabb49da" +sha256 = "660d2aace45ec1a28bc7e3708eab71ffee5cb1203f57a6256f7bd7c727193567" [[asset]] path = "assets/app-icon/hicolor/24x24/apps/ai.storyteller.pdfcraft.png" title = "PdfCraft app icon, 24 px" -author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)" +author = "PdfCraft contributors (drawn by the project owner in ArtCraft)" source = "Contributor-original (see assets/app-icon/README.md)" licence = "MIT OR Apache-2.0" licence_file = "assets/app-icon/LICENSE.txt" kind = "icon" usage = "Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop)" -sha256 = "a4cd466687f6c5cb59eef3fc564bdba1c8f0d9e324754eeebab82f18823efae7" +sha256 = "1989441bdcc8d1df0d73cfe7eb836e9211abaa0fd7fd6ec7e4634bfcd472995d" [[asset]] path = "assets/app-icon/hicolor/32x32/apps/ai.storyteller.pdfcraft.png" title = "PdfCraft app icon, 32 px" -author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)" +author = "PdfCraft contributors (drawn by the project owner in ArtCraft)" source = "Contributor-original (see assets/app-icon/README.md)" licence = "MIT OR Apache-2.0" licence_file = "assets/app-icon/LICENSE.txt" kind = "icon" usage = "Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop)" -sha256 = "e22279013a5ed5c02571e1e4e9bc4cef9aa0847fcb733a285b2916fabd16bf78" +sha256 = "7fd1ac555f7f6b13ef3fba2696dfb86900cfcb7f7b669aece38590bf2306cf2e" [[asset]] path = "assets/app-icon/hicolor/48x48/apps/ai.storyteller.pdfcraft.png" title = "PdfCraft app icon, 48 px" -author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)" +author = "PdfCraft contributors (drawn by the project owner in ArtCraft)" source = "Contributor-original (see assets/app-icon/README.md)" licence = "MIT OR Apache-2.0" licence_file = "assets/app-icon/LICENSE.txt" kind = "icon" usage = "Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop)" -sha256 = "55daf2d5575f6c4e4c4975c9f1c74231178f1801963a8b19f68dfa9bc50cb22f" +sha256 = "784ef3bd9f9e479cb2df22a0aee259a441a9c358390e646639c3a0ed79d41288" [[asset]] path = "assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" title = "PdfCraft app icon, 64 px" -author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)" +author = "PdfCraft contributors (drawn by the project owner in ArtCraft)" source = "Contributor-original (see assets/app-icon/README.md)" licence = "MIT OR Apache-2.0" licence_file = "assets/app-icon/LICENSE.txt" kind = "icon" usage = "Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop)" -sha256 = "2975c200f341e247b5fa18f568fb27d390e3fa01e9b2ad37acb48d0e1cc8a124" +sha256 = "deb1a9f849549d3f0652fb6d3c30b6eb6ea54498eaea8c1007d901bbafe7067c" [[asset]] path = "assets/app-icon/hicolor/128x128/apps/ai.storyteller.pdfcraft.png" title = "PdfCraft app icon, 128 px" -author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)" +author = "PdfCraft contributors (drawn by the project owner in ArtCraft)" source = "Contributor-original (see assets/app-icon/README.md)" licence = "MIT OR Apache-2.0" licence_file = "assets/app-icon/LICENSE.txt" kind = "icon" usage = "Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop)" -sha256 = "6ef927ca252b40bc9b5d8843d84137334362f8aa1acbb1bf8c15a02affd25b2b" +sha256 = "1933bbeaea290497facd328e917dafcc433d0c0caa9f1e207aa45ac1e430898c" [[asset]] path = "assets/app-icon/hicolor/256x256/apps/ai.storyteller.pdfcraft.png" title = "PdfCraft app icon, 256 px" -author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)" +author = "PdfCraft contributors (drawn by the project owner in ArtCraft)" source = "Contributor-original (see assets/app-icon/README.md)" licence = "MIT OR Apache-2.0" licence_file = "assets/app-icon/LICENSE.txt" kind = "icon" usage = "Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop); runtime window icon on Windows and Linux (apps/pdfcraft/src/main.rs)" -sha256 = "cde7ee8c5ee38f9fc40a429fd9e6d99a035bcd321701fce331bb13bac126614d" +sha256 = "47094c09fb77eb35d465eee46445626a4d78c7ef50629cc7d04a602de3e144e9" [[asset]] path = "assets/app-icon/hicolor/512x512/apps/ai.storyteller.pdfcraft.png" title = "PdfCraft app icon, 512 px" -author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)" +author = "PdfCraft contributors (drawn by the project owner in ArtCraft)" source = "Contributor-original (see assets/app-icon/README.md)" licence = "MIT OR Apache-2.0" licence_file = "assets/app-icon/LICENSE.txt" kind = "icon" usage = "Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop)" -sha256 = "5ede733e93b96f4eb8d94299232cf2f9605fa19b0d913f95fe9828c316a2d066" +sha256 = "dea8010a8b41011aba054d3bfb8b790da57bc958c60869f508e1bbd288d3f280" [[asset]] path = "assets/app-icon/hicolor/scalable/apps/ai.storyteller.pdfcraft.svg" title = "PdfCraft app icon (Linux scalable)" -author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)" +author = "PdfCraft contributors (drawn by the project owner in ArtCraft)" source = "Contributor-original (see assets/app-icon/README.md)" licence = "MIT OR Apache-2.0" licence_file = "assets/app-icon/LICENSE.txt" kind = "icon" usage = "Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop)" -sha256 = "52b384a10a2a7fdd3145d9822972cfd568ef0cb7312549bd4399addd89d45a6d" +sha256 = "27745c021830f82c033b0f15f2d8d9f8e099138743297887ff7e2c9e7ed069da" [[asset]] path = "docs/images/pdfcraft-bookmarks.png" @@ -3194,7 +3194,7 @@ path = "assets/icons/pin.svg" title = "Lucide icon \"pin\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pin.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -3205,7 +3205,7 @@ path = "assets/icons/move-right.svg" title = "Lucide icon \"move-right\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/move-right.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -3216,7 +3216,7 @@ path = "assets/icons/reply.svg" title = "Lucide icon \"reply\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/reply.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -3227,7 +3227,7 @@ path = "assets/icons/thumbs-up.svg" title = "Lucide icon \"thumbs-up\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/thumbs-up.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -3238,7 +3238,7 @@ path = "assets/icons/thumbs-down.svg" title = "Lucide icon \"thumbs-down\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/thumbs-down.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -3249,7 +3249,7 @@ path = "assets/icons/message-square-plus.svg" title = "Lucide icon \"message-square-plus\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-square-plus.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -3260,7 +3260,7 @@ path = "assets/icons/circle-x.svg" title = "Lucide icon \"circle-x\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-x.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -3282,7 +3282,7 @@ path = "assets/icons/pentagon.svg" title = "Lucide icon \"pentagon\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pentagon.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -3293,7 +3293,7 @@ path = "assets/icons/message-square-quote.svg" title = "Lucide icon \"message-square-quote\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-square-quote.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -3315,7 +3315,7 @@ path = "assets/icons/clipboard-paste.svg" title = "Lucide icon \"clipboard-paste\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/clipboard-paste.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -3341,7 +3341,7 @@ licence = "MIT OR Apache-2.0" licence_file = "assets/app-icon/LICENSE.txt" kind = "image" usage = "Finder window of the macOS DMG; rendered to background.tiff by packaging/macos/dmg/generate.py" -sha256 = "e8b9ea9d5e64b7b741c977543c1693825e77d5c4c579eeb674bf5dcd3764cd8a" +sha256 = "182663e4d1c4fabbb7ab2e1f0d4e991a8e0859b4a8dee6934d3e25d77ea020af" [[asset]] @@ -3353,33 +3353,33 @@ licence = "MIT OR Apache-2.0" licence_file = "assets/app-icon/LICENSE.txt" kind = "image" usage = "1x + 2x HiDPI 16-colour palette TIFF copied into the macOS DMG by packaging/macos/package.sh; includes the app icon and the free LittleCMS built-in sRGB profile" -sha256 = "f46881724a6621f3686a612a79dbdce4f7dbe9e2025610e44c9a094341334c2c" +sha256 = "bedf1b23707b9e34157440e83f65464ff314fa9f6b1d39c7a8930d1645c2f8be" [[fetched]] -file = "text-detection.rten" -url = "https://ocrs-models.s3-accelerate.amazonaws.com/text-detection.rten" +file = "text-detection.onnx" +url = "https://ocrs-models.s3-accelerate.amazonaws.com/text-detection.onnx" title = "Ocrs text detection model" author = "Robert Knight (ocrs-models; trained on HierText, CC-BY-SA-4.0)" -source = "https://github.com/robertknight/ocrs-models (the models ocrs downloads; model card: https://huggingface.co/robertknight/ocrs, cc-by-sa-4.0)" +source = "https://github.com/robertknight/ocrs/blob/0c826441362a701c55560fefc17bf356b6d1037e/ocrs/examples/download-models.sh (author-published ONNX models; model card: https://huggingface.co/robertknight/ocrs/blob/df0edd170279ab971b53e094c627255a87e1a503/README.md, cc-by-sa-4.0)" licence = "CC-BY-SA-4.0" licence_url = "https://creativecommons.org/licenses/by-sa/4.0/legalcode.txt" licence_sha256 = "28a9529c7d0bb4dc51f4bf5c116a3d16ef247a052f7591466768ddf563fd1cf5" kind = "model" usage = "Scan & OCR: finds words on page images (cargo xtask models)" -sha256 = "f15cfb56bd02c4bf478a20343986504a1f01e1665c2b3a0ad66340f054b1b5ca" +sha256 = "a917b23dbd9524b465df7e922641b3ff2981623df4ded5a0234004ef2fee7cfe" [[fetched]] -file = "text-recognition.rten" -url = "https://ocrs-models.s3-accelerate.amazonaws.com/text-recognition.rten" +file = "text-recognition.onnx" +url = "https://ocrs-models.s3-accelerate.amazonaws.com/text-recognition.onnx" title = "Ocrs text recognition model" author = "Robert Knight (ocrs-models; trained on HierText, CC-BY-SA-4.0)" -source = "https://github.com/robertknight/ocrs-models (the models ocrs downloads; model card: https://huggingface.co/robertknight/ocrs, cc-by-sa-4.0)" +source = "https://github.com/robertknight/ocrs/blob/0c826441362a701c55560fefc17bf356b6d1037e/ocrs/examples/download-models.sh (author-published ONNX models; model card: https://huggingface.co/robertknight/ocrs/blob/df0edd170279ab971b53e094c627255a87e1a503/README.md, cc-by-sa-4.0)" licence = "CC-BY-SA-4.0" licence_url = "https://creativecommons.org/licenses/by-sa/4.0/legalcode.txt" licence_sha256 = "28a9529c7d0bb4dc51f4bf5c116a3d16ef247a052f7591466768ddf563fd1cf5" kind = "model" usage = "Scan & OCR: reads the words it finds (cargo xtask models)" -sha256 = "e484866d4cce403175bd8d00b128feb08ab42e208de30e42cd9889d8f1735a6e" +sha256 = "86c145c2edb96c8caed5b1ebb8f44d706408922211451c309c625157dd6061c5" [[build_input]] name = "craft-fonts" @@ -3408,7 +3408,7 @@ path = "assets/icons/monitor.svg" title = "Lucide icon \"monitor\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/monitor.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -3422,7 +3422,7 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI 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" +usage = "Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/sl.tsv" @@ -3432,7 +3432,7 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI 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" +usage = "Slovene UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] @@ -3454,14 +3454,14 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Norwegian Bokmål UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Norwegian Bokmål UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "assets/icons/superscript.svg" title = "Lucide icon \"superscript\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/superscript.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -3472,7 +3472,7 @@ path = "assets/icons/subscript.svg" title = "Lucide icon \"subscript\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/subscript.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -3483,7 +3483,7 @@ path = "assets/icons/pilcrow.svg" title = "Lucide icon \"pilcrow\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pilcrow.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -3494,7 +3494,7 @@ path = "assets/icons/move-horizontal.svg" title = "Lucide icon \"move-horizontal\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/move-horizontal.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -3508,7 +3508,7 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Albanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Albanian UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/th.tsv" @@ -3518,7 +3518,7 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Thai UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Thai UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/ca.tsv" @@ -3528,4 +3528,4 @@ source = "Contributor-original: clean-room translations of PdfCraft's English UI licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Catalan UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +usage = "Catalan UI strings, compiled in by crates/ui-egui/src/i18n.rs" diff --git a/CLAUDE.md b/CLAUDE.md index f51a96d56e..5e9328f8d3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Ado ## Start every session here 1. Read `plan/STATUS.md`: the current milestone, the next unchecked task and any blockers. `ROADMAP.md` is the one-page summary (stage, numbers, progress log) and `docs/roadmap.md` the milestone table; at the end of every session update the milestone row and add a line to the ROADMAP.md progress log. - - Read `docs/gaps.md` (the ranked gaps: Acrobat interop, the open-issue backlog, the renderer and a fidelity harness, editing existing content, Pro workflows, 1.0 polish) and `docs/target-app-parity.md` (the numbers and how they are measured) before choosing work. Prefer the gaps over new P2/P3 features, and keep both documents true when things change (`docs/` progress docs follow craftrules `standards/progress-docs.md`: status line, revision history). + - Read `docs/gaps.md` (the ranked gaps: Acrobat interop, the open-issue backlog, the renderer and a fidelity harness, editing existing content, Pro workflows, 1.0 polish) and `docs/target-app-parity.md` (the numbers and how they are measured) before choosing work. Prefer the gaps over new P2/P3 features, and keep both documents true when things change (`docs/` progress docs carry a status line and a revision history). - "Shipped" in `parity/` means "exists and tested", not "as good as Acrobat". Don't mark a feature shipped on a generic test, and say in its notes what is still missing. 2. Read that task in `plan/execution-plan.md` §3, the relevant section of `plan/architecture.md`, and the README of the crate you're touching. 3. Follow the **autonomous operation protocol** in `plan/execution-plan.md` §7 (orient → plan → implement + test → verify → record → commit). Don't stop to ask unless §7 lists the decision as the user's. @@ -13,7 +13,7 @@ PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Ado ## Non-negotiables - **Assets: read `AGENTS.md` §1 before adding or showing any icon, image, font or document.** No assets from Adobe products, ever. Only openly licensed or contributor-original assets are allowed, each with an entry in `ATTRIBUTION.toml`. `cargo xtask assets` enforces this. `AGENTS.md` overrides this file. -- **Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts).** Never commit font files here (`AGENTS.md` §1.4; team members: [craftrules `standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md), internal). It is the optional build input `CRAFT_FONTS_DIR`: `git clone https://github.com/storytold/craft-fonts ../craft-fonts && CRAFT_FONTS_DIR=../craft-fonts cargo test --workspace` embeds the Japanese fonts (UI fallback, Japanese text in edited PDFs) and runs their tests, which otherwise skip. Code using `pdfcraft_fonts::CRAFT_FONTS` must work when it is empty. +- **Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts).** Never commit font files here (`AGENTS.md` §1.4). It is the optional build input `CRAFT_FONTS_DIR`: `git clone https://github.com/storytold/craft-fonts ../craft-fonts && CRAFT_FONTS_DIR=../craft-fonts cargo test --workspace` embeds the Japanese fonts (UI fallback, Japanese text in edited PDFs) and runs their tests, which otherwise skip. Code using `pdfcraft_fonts::CRAFT_FONTS` must work when it is empty. - **Clean-room.** - Never read, disassemble or copy anything inside the Acrobat bundle (names and listings only). **Never open `Contents/Resources/JavaScripts/`.** - Behaviour comes from public docs, specs (ISO 32000-2, the Arlington model) and black-box observation (`plan/acrobat/`). @@ -22,6 +22,8 @@ PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Ado - **Privacy.** When observing Acrobat, use synthetic fixtures only. Never capture the Home view, recent files or account info. Capture by window id (`plan/acrobat/tools/`). Never commit Acrobat outputs, corpus files or personal data. - **Layering.** Nothing below L7 depends on egui/winit/eframe/rfd (`plan/architecture.md` §3). `cos`/`filters`/`crypt`/`arlington` stay standalone. - **Page input.** Page modules read `PagePointer` (`crates/ui-egui/src/page_pointer.rs`), never egui's raw pointer state (`hover_pos`, `interact_pos`, `latest_pos`, `press_origin`, button reads); `page_pointer::tests::page_modules_read_the_pointer_only_through_page_pointer` enforces it. +- **Page cursor.** Page modules claim the cursor for what is under the pointer (`view.cursor.claim(Layer::…, icon)`, `crates/ui-egui/src/canvas/page_cursor.rs`), never set it (`set_cursor_icon`, `cursor_icon =`, `on_hover_cursor`); `document_area` sets it once, from the claim on the highest layer the active tool reaches. `page_pointer::tests::page_modules_claim_the_cursor_and_never_set_it` enforces it. +- **Page presses.** A page module decides what of its own is under a point in one hit test, which returns a `Hit` (owner, layer, cursor, tip; `crates/ui-egui/src/canvas/page_router.rs`) and gives its cursor, its tip and its press. It acts on a press only when the router says the press is its own (`view.router.owner(page)`), never by testing the press point again or by asking another module whether it wants it. Escape cancels any press the pages hold (`cancel_on_escape`), and that press is ignored until the button is up. `page_modules_take_presses_from_the_router_without_repeating_hit_tests` guards the centralized hit calls and captured-owner handlers; runtime routing tests cover gesture behavior. - **Fidelity.** The PDF object graph is the model. Preserve unknown data. Saves are incremental unless a full rewrite is required. Never silently drop or repair data without recording it. - **Never panic.** Every PDF, script, MCP call, settings file and keystroke is untrusted input, and none of it may crash the app or lose the user's work. This outranks feature work. Return `Result` (the crate's error enum) and propagate with `?`, or fall back leniently and record it. In non-test code: - No `unwrap`/`expect`/`panic!`/`unreachable!`/`todo!` unless it is provably infallible, with a comment saying why. @@ -30,7 +32,7 @@ PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Ado - Cap allocations sized by input, and bound recursion with depth limits or seen-sets. - Handle lock poisoning. - No `unsafe` (`unsafe_code = "forbid"`). - - Every crash fix gets a synthetic regression test. See `AGENTS.md` §4 (and, for team members, the internal `craftrules/standards/never-crash.md`). + - Every crash fix gets a synthetic regression test. See `AGENTS.md` §4. - **Rust only** in the product and build (`xtask`). No handwritten JS/TS. - **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic). - **Review every update as you make it, at four zooms.** Not an end-of-task pass — a per-update habit, so a defect never gets built on. **Clause:** checked arithmetic, `get()` not `[i]`, char-boundary slicing, the right error through `?`, `<` against `<=`, the 1-based/0-based seam, capped allocations. **Function:** is it total — name an input that panics; walk empty, zero, max, malformed, cyclic; is the return type honest. **File/module:** two sources of truth, dead or duplicated code, is the public surface still smallest and the doc comment still true. **Project:** layering, wasm, undo/redo, save fidelity, encryption and permissions, a matching `automation` tool or registry entry, and whether a `parity/` status or note just became untrue. Scale the zoom to the change, and fix what a level turns up before widening. @@ -39,6 +41,7 @@ PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Ado - **Rebasing is a review step, not a mechanical one.** Read every hunk you resolve, and the auto-merged hunks in the files you touched too — `main` moves fast enough that a file can change by hundreds of lines under a branch. - **External PRs are held to safety, security and completeness before style.** Provenance first (no Adobe-derived asset, no committed font, no copied GPL/AGPL code, behaviour from the spec and not from reading another implementation), then no new panic path or `unsafe`, then `--root` confinement on every path including ones reached by nested dispatch, opt-in-only MCP and control channel, no new outbound network, then a headless `automation` tool, tests at the right zoom and an honest `parity/` entry. Provenance is the part a contributor cannot be expected to know, and the only part that cannot be undone after merge. - **Quality gates** before every commit: `cargo fmt --check`, clippy with `-D warnings`, the tests, `cargo xtask layers`, `cargo xtask assets`, and the wasm check once `xtask ci` exists (M0). + - New workspace crates must include `[lints] workspace = true` in `Cargo.toml` to inherit `[workspace.lints]`. Production crate roots must declare the required crate-level lint attributes before modules or other crate items; crate-level `//!` documentation may precede them. - **Scope builds and tests to the crates you touched** — `cargo clippy -p -j 2 --all-targets` and `cargo test -p -j 2`, under a `timeout`. A full `--workspace` build has locked up a development machine's CPU; it is CI's job and CI runs it on every PR. Run the workspace sweep locally only when you have a reason to and the machine is free. - **Check for existing work before starting a fix or feature.** Search the issues and PRs, open and closed, with `gh issue list -R storytold/pdfcraft --state all --search ""` and the same with `gh pr list` (try a few phrasings), and check whether a merged fix is already on `main` (`git merge-base --is-ancestor origin/main`). If one is, the report is likely from an older build: say so rather than fixing it again. If an open PR covers it, review or build on that PR instead of opening a competing one. If an issue exists, reference it in the PR (`Fixes #123`). - **Commits and PRs:** `main` is the only integration branch and is **never pushed to directly**. Branch from an up-to-date `main` as `/` (`feat/`, `fix/`, `docs/`, `chore/`, `refactor/`), one task id per commit (e.g. `M1.4: xref stream reader`), commit only green states, and open a PR into `main` using `.github/pull_request_template.md`. Merge only when CI is green. @@ -55,3 +58,8 @@ PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Ado ## Current bootstrap debt (tracked in STATUS.md) - `pdfcraft-render` renders through the `hayro` crate directly and inspects documents through `lopdf`. Both get replaced by `cos` / `model` / the DisplayList device (M1–M2, ADR-0004). + +## CI workflows +- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here. +- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time. +- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it. diff --git a/Cargo.lock b/Cargo.lock index 3c713ba5cc..c46eac4790 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -229,7 +229,7 @@ dependencies = [ "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.59.0", + "windows-sys 0.61.2", "wl-clipboard-rs", "x11rb", ] @@ -1023,6 +1023,15 @@ dependencies = [ "x509-cert", ] +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.21", +] + [[package]] name = "codespan-reporting" version = "0.13.1" @@ -1169,12 +1178,40 @@ dependencies = [ [[package]] name = "craft-docpath" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63" +source = "git+https://github.com/storytold/craft-libs?rev=787a7a0f972fd1d7a7618fdbeca3a5267a0407ff#787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" + +[[package]] +name = "craft-fonts-build" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=787a7a0f972fd1d7a7618fdbeca3a5267a0407ff#787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" + +[[package]] +name = "craft-i18n" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=787a7a0f972fd1d7a7618fdbeca3a5267a0407ff#787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" +dependencies = [ + "fixed_decimal", + "icu_decimal", + "icu_locale_core", + "icu_locale_fallback", + "icu_normalizer", + "icu_plurals", + "icu_properties", + "icu_provider", + "icu_provider_adapters", + "icu_provider_blob", + "log", + "objc2-foundation 0.3.2", + "sys-locale", + "unicode-bidi", + "windows-registry", + "writeable", +] [[package]] name = "craft-keymap" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=edc41f68fa24c46651c3b5117d10f0ead75ec9cb#edc41f68fa24c46651c3b5117d10f0ead75ec9cb" +source = "git+https://github.com/storytold/craft-libs?rev=787a7a0f972fd1d7a7618fdbeca3a5267a0407ff#787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" dependencies = [ "egui", "serde", @@ -1184,7 +1221,7 @@ dependencies = [ [[package]] name = "craft-menu" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=edc41f68fa24c46651c3b5117d10f0ead75ec9cb#edc41f68fa24c46651c3b5117d10f0ead75ec9cb" +source = "git+https://github.com/storytold/craft-libs?rev=787a7a0f972fd1d7a7618fdbeca3a5267a0407ff#787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" dependencies = [ "craft-keymap", "craft-quit", @@ -1197,11 +1234,12 @@ dependencies = [ [[package]] name = "craft-print" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=42e30cd5b3f4b114270dd053fccbc33bedc47f68#42e30cd5b3f4b114270dd053fccbc33bedc47f68" +source = "git+https://github.com/storytold/craft-libs?rev=787a7a0f972fd1d7a7618fdbeca3a5267a0407ff#787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" dependencies = [ "craft-winprint-gdi", - "miniz_oxide 0.8.9", - "roxmltree", + "log", + "miniz_oxide 0.9.1", + "roxmltree 0.21.2", "tempfile", "winprint", "winsafe", @@ -1210,7 +1248,7 @@ dependencies = [ [[package]] name = "craft-quit" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=edc41f68fa24c46651c3b5117d10f0ead75ec9cb#edc41f68fa24c46651c3b5117d10f0ead75ec9cb" +source = "git+https://github.com/storytold/craft-libs?rev=787a7a0f972fd1d7a7618fdbeca3a5267a0407ff#787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" dependencies = [ "dispatch2", "egui", @@ -1221,26 +1259,35 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=edc41f68fa24c46651c3b5117d10f0ead75ec9cb#edc41f68fa24c46651c3b5117d10f0ead75ec9cb" +source = "git+https://github.com/storytold/craft-libs?rev=787a7a0f972fd1d7a7618fdbeca3a5267a0407ff#787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" dependencies = [ + "craft-fonts-build", + "craft-i18n", "egui", + "epaint", + "js-sys", "log", "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "serde", + "skrifa 0.44.0", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", ] [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184" +source = "git+https://github.com/storytold/craft-libs?rev=787a7a0f972fd1d7a7618fdbeca3a5267a0407ff#787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" dependencies = [ "chrono", "dunce", "egui", "flate2", "getrandom 0.3.4", + "image", "log", "plist", "semver", @@ -1970,7 +2017,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2073,6 +2120,17 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +[[package]] +name = "fixed_decimal" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2214fb00f669ee96bee5bb29dfb1ddabe058b2bc426817efc79afa03c9bc55ae" +dependencies = [ + "displaydoc", + "smallvec", + "writeable", +] + [[package]] name = "fixedbitset" version = "0.5.7" @@ -2188,7 +2246,7 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" dependencies = [ - "roxmltree", + "roxmltree 0.20.0", ] [[package]] @@ -2675,6 +2733,7 @@ version = "0.3.0" dependencies = [ "fearless_simd", "hayro-ccitt", + "image", ] [[package]] @@ -2882,6 +2941,23 @@ dependencies = [ "zerovec", ] +[[package]] +name = "icu_decimal" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eb8f655bba2d0c0459e43a5b0e6cd3cd388109898fb3d85d048165e8b0abd08" +dependencies = [ + "displaydoc", + "fixed_decimal", + "icu_locale_core", + "icu_pattern", + "icu_plurals", + "icu_provider", + "serde", + "writeable", + "zerovec", +] + [[package]] name = "icu_locale_core" version = "2.3.0" @@ -2890,11 +2966,32 @@ checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", + "serde", "tinystr", "writeable", "zerovec", ] +[[package]] +name = "icu_locale_fallback" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9" +dependencies = [ + "icu_locale_core", + "icu_locale_fallback_data", + "icu_provider", + "potential_utf", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locale_fallback_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8" + [[package]] name = "icu_normalizer" version = "2.3.0" @@ -2907,6 +3004,7 @@ dependencies = [ "icu_provider", "smallvec", "utf16_iter", + "utf8_iter", "write16", "zerovec", ] @@ -2917,6 +3015,32 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" +[[package]] +name = "icu_pattern" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b3db611446bd92c246b03e4aa0bafa71ddb194af431ddbb42b9d70e4bfce1a0" +dependencies = [ + "displaydoc", + "either", + "serde", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_plurals" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e475e6766ef87b1d3c1f97be6363995b0bfaeddbc789671615df598a97ff0593" +dependencies = [ + "displaydoc", + "fixed_decimal", + "icu_provider", + "serde", + "zerovec", +] + [[package]] name = "icu_properties" version = "2.3.0" @@ -2946,6 +3070,9 @@ checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", + "postcard", + "serde", + "stable_deref_trait", "writeable", "yoke", "zerofrom", @@ -2953,6 +3080,30 @@ dependencies = [ "zerovec", ] +[[package]] +name = "icu_provider_adapters" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df887bc4f55dbc773c7c45a0c7dfa75be886f7b7812830a71ab7271cc1801820" +dependencies = [ + "icu_locale_fallback", + "icu_provider", +] + +[[package]] +name = "icu_provider_blob" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5916b44fac319d3f58e4d8d805a627df19e27937b582cd99fcde3bb6b7ce1b0e" +dependencies = [ + "icu_provider", + "postcard", + "serde", + "writeable", + "zerotrie", + "zerovec", +] + [[package]] name = "idna" version = "1.1.0" @@ -3251,6 +3402,9 @@ name = "litemap" version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" +dependencies = [ + "serde_core", +] [[package]] name = "litrs" @@ -3963,7 +4117,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4054,7 +4208,7 @@ dependencies = [ [[package]] name = "pdfcraft" -version = "0.14.0" +version = "0.18.0" dependencies = [ "chrono", "craft-update", @@ -4078,7 +4232,7 @@ dependencies = [ [[package]] name = "pdfcraft-a11y" -version = "0.14.0" +version = "0.18.0" dependencies = [ "pdfcraft-content", "pdfcraft-cos", @@ -4090,7 +4244,7 @@ dependencies = [ [[package]] name = "pdfcraft-annot" -version = "0.14.0" +version = "0.18.0" dependencies = [ "hayro-syntax", "pdfcraft-cos", @@ -4101,7 +4255,7 @@ dependencies = [ [[package]] name = "pdfcraft-audit" -version = "0.14.0" +version = "0.18.0" dependencies = [ "pdfcraft-content", "pdfcraft-cos", @@ -4112,7 +4266,7 @@ dependencies = [ [[package]] name = "pdfcraft-automation" -version = "0.14.0" +version = "0.18.0" dependencies = [ "base64 0.23.1", "flate2", @@ -4134,7 +4288,7 @@ dependencies = [ [[package]] name = "pdfcraft-cli" -version = "0.14.0" +version = "0.18.0" dependencies = [ "base64 0.23.1", "pdfcraft-audit", @@ -4152,11 +4306,11 @@ dependencies = [ [[package]] name = "pdfcraft-compare" -version = "0.14.0" +version = "0.18.0" [[package]] name = "pdfcraft-content" -version = "0.14.0" +version = "0.18.0" dependencies = [ "hayro-syntax", "pdfcraft-cos", @@ -4164,7 +4318,7 @@ dependencies = [ [[package]] name = "pdfcraft-cos" -version = "0.14.0" +version = "0.18.0" dependencies = [ "chrono", "hayro-syntax", @@ -4179,14 +4333,17 @@ dependencies = [ [[package]] name = "pdfcraft-create" -version = "0.14.0" +version = "0.18.0" dependencies = [ "encoding_rs", + "hayro-ccitt", "hayro-syntax", "image", "pdfcraft-cos", + "pdfcraft-filters", "pdfcraft-fonts", "pdfcraft-model", + "pdfcraft-xfa", "png 0.18.1", "thiserror 2.0.21", "tiff", @@ -4195,7 +4352,7 @@ dependencies = [ [[package]] name = "pdfcraft-crypt" -version = "0.14.0" +version = "0.18.0" dependencies = [ "aes", "md-5", @@ -4207,7 +4364,7 @@ dependencies = [ [[package]] name = "pdfcraft-edit" -version = "0.14.0" +version = "0.18.0" dependencies = [ "hayro-syntax", "kurbo 0.13.1", @@ -4216,16 +4373,20 @@ dependencies = [ "pdfcraft-cos", "pdfcraft-fonts", "pdfcraft-model", + "pdfcraft-render", + "proptest", + "sha2 0.11.1", "thiserror 2.0.21", "unicode-bidi", ] [[package]] name = "pdfcraft-engine" -version = "0.14.0" +version = "0.18.0" dependencies = [ "chrono", "craft-docpath", + "getrandom 0.4.3", "icu_properties", "idna", "image", @@ -4233,10 +4394,12 @@ dependencies = [ "pdfcraft-annot", "pdfcraft-audit", "pdfcraft-compare", + "pdfcraft-content", "pdfcraft-cos", "pdfcraft-create", "pdfcraft-edit", "pdfcraft-export", + "pdfcraft-filters", "pdfcraft-fonts", "pdfcraft-forms", "pdfcraft-geom", @@ -4263,16 +4426,19 @@ dependencies = [ [[package]] name = "pdfcraft-export" -version = "0.14.0" +version = "0.18.0" dependencies = [ "flate2", ] [[package]] name = "pdfcraft-filters" -version = "0.14.0" +version = "0.18.0" dependencies = [ + "fax", "flate2", + "hayro-ccitt", + "hayro-jbig2", "proptest", "thiserror 2.0.21", "weezl 0.2.1", @@ -4280,7 +4446,7 @@ dependencies = [ [[package]] name = "pdfcraft-fonts" -version = "0.14.0" +version = "0.18.0" dependencies = [ "harfrust", "pdfcraft-cos", @@ -4290,7 +4456,7 @@ dependencies = [ [[package]] name = "pdfcraft-forms" -version = "0.14.0" +version = "0.18.0" dependencies = [ "hayro-syntax", "pdfcraft-content", @@ -4303,11 +4469,11 @@ dependencies = [ [[package]] name = "pdfcraft-geom" -version = "0.14.0" +version = "0.18.0" [[package]] name = "pdfcraft-js" -version = "0.14.0" +version = "0.18.0" dependencies = [ "boa_engine", "chrono", @@ -4315,7 +4481,7 @@ dependencies = [ [[package]] name = "pdfcraft-measure" -version = "0.14.0" +version = "0.18.0" dependencies = [ "pdfcraft-annot", "pdfcraft-content", @@ -4328,24 +4494,25 @@ dependencies = [ [[package]] name = "pdfcraft-model" -version = "0.14.0" +version = "0.18.0" dependencies = [ "pdfcraft-cos", ] [[package]] name = "pdfcraft-ocr" -version = "0.14.0" +version = "0.18.0" dependencies = [ "ocrs", "pdfcraft-fonts", "rten", + "tempfile", "thiserror 2.0.21", ] [[package]] name = "pdfcraft-optimize" -version = "0.14.0" +version = "0.18.0" dependencies = [ "image", "pdfcraft-annot", @@ -4354,17 +4521,18 @@ dependencies = [ "pdfcraft-filters", "pdfcraft-model", "pdfcraft-render", + "proptest", "thiserror 2.0.21", "zune-jpeg", ] [[package]] name = "pdfcraft-oracle" -version = "0.14.0" +version = "0.18.0" [[package]] name = "pdfcraft-organize" -version = "0.14.0" +version = "0.18.0" dependencies = [ "hayro-syntax", "pdfcraft-content", @@ -4375,7 +4543,7 @@ dependencies = [ [[package]] name = "pdfcraft-platform" -version = "0.14.0" +version = "0.18.0" dependencies = [ "async-channel", "futures-lite", @@ -4385,18 +4553,18 @@ dependencies = [ [[package]] name = "pdfcraft-preflight" -version = "0.14.0" +version = "0.18.0" dependencies = [ "flate2", "pdfcraft-content", "pdfcraft-cos", "pdfcraft-model", - "roxmltree", + "roxmltree 0.21.2", ] [[package]] name = "pdfcraft-print" -version = "0.14.0" +version = "0.18.0" dependencies = [ "craft-print", "pdfcraft-content", @@ -4408,12 +4576,13 @@ dependencies = [ [[package]] name = "pdfcraft-redact" -version = "0.14.0" +version = "0.18.0" dependencies = [ "pdfcraft-annot", "pdfcraft-content", "pdfcraft-cos", "pdfcraft-edit", + "pdfcraft-filters", "pdfcraft-fonts", "pdfcraft-forms", "pdfcraft-model", @@ -4424,7 +4593,7 @@ dependencies = [ [[package]] name = "pdfcraft-render" -version = "0.14.0" +version = "0.18.0" dependencies = [ "bytemuck", "flate2", @@ -4443,7 +4612,7 @@ dependencies = [ [[package]] name = "pdfcraft-sign" -version = "0.14.0" +version = "0.18.0" dependencies = [ "aes", "aws-lc-rs", @@ -4479,11 +4648,11 @@ dependencies = [ [[package]] name = "pdfcraft-testkit" -version = "0.14.0" +version = "0.18.0" [[package]] name = "pdfcraft-ui-egui" -version = "0.14.0" +version = "0.18.0" dependencies = [ "arboard", "base64 0.23.1", @@ -4507,6 +4676,7 @@ dependencies = [ "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", + "pdfcraft-content", "pdfcraft-cos", "pdfcraft-engine", "pdfcraft-fonts", @@ -4517,6 +4687,7 @@ dependencies = [ "plist", "png 0.18.1", "pollster 1.0.1", + "proptest", "rfd", "rustix 1.1.5", "serde", @@ -4535,7 +4706,7 @@ dependencies = [ [[package]] name = "pdfcraft-web" -version = "0.14.0" +version = "0.18.0" dependencies = [ "eframe", "egui", @@ -4547,27 +4718,27 @@ dependencies = [ [[package]] name = "pdfcraft-xfa" -version = "0.14.0" +version = "0.18.0" dependencies = [ "base64 0.23.1", "image", "pdfcraft-cos", "pdfcraft-fonts", "png 0.18.1", - "roxmltree", + "roxmltree 0.21.2", "sha2 0.11.1", "thiserror 2.0.21", ] [[package]] name = "pdfcraft-xfdf" -version = "0.14.0" +version = "0.18.0" dependencies = [ "pdfcraft-annot", "pdfcraft-cos", "pdfcraft-forms", "pdfcraft-model", - "roxmltree", + "roxmltree 0.21.2", "thiserror 2.0.21", ] @@ -4888,12 +5059,24 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "serde", +] + [[package]] name = "potential_utf" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ + "serde_core", + "writeable", "zerovec", ] @@ -5094,7 +5277,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5444,6 +5627,15 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" +[[package]] +name = "roxmltree" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9da7f59328b09405b181fee1018925fad50f157afb43c0c0f5c522c256dd5907" +dependencies = [ + "memchr", +] + [[package]] name = "rsa" version = "0.10.0-rc.18" @@ -5473,6 +5665,7 @@ dependencies = [ "rten-base", "rten-gemm", "rten-model-file", + "rten-onnx", "rten-parallel", "rten-shape-inference", "rten-simd", @@ -5522,6 +5715,12 @@ dependencies = [ "rten-base", ] +[[package]] +name = "rten-onnx" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96944374568990b8d1bbc5bae5957585866f9dd8256130460405730948bfd42e" + [[package]] name = "rten-parallel" version = "0.26.0" @@ -5615,7 +5814,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5685,7 +5884,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6515,7 +6714,10 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" dependencies = [ + "js-sys", "libc", + "wasm-bindgen", + "web-sys", ] [[package]] @@ -6572,7 +6774,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.5", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6710,6 +6912,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", + "serde_core", "zerovec", ] @@ -7078,7 +7281,7 @@ dependencies = [ "kurbo 0.11.3", "log", "pico-args", - "roxmltree", + "roxmltree 0.20.0", "simplecss", "siphasher", "strict-num", @@ -7166,8 +7369,6 @@ dependencies = [ [[package]] name = "vello_cpu" version = "0.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d8ded630e8316bb94a55881256506d1f3b9947b5f66db8a7d32ca7ba02decd0" dependencies = [ "bytemuck", "glifo 0.1.1", @@ -7684,7 +7885,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -8052,6 +8253,9 @@ name = "writeable" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +dependencies = [ + "either", +] [[package]] name = "x11-dl" @@ -8376,8 +8580,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", + "litemap", + "serde_core", "yoke", "zerofrom", + "zerovec", ] [[package]] @@ -8386,6 +8593,7 @@ version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ + "serde", "yoke", "zerofrom", "zerovec-derive", @@ -8404,9 +8612,9 @@ dependencies = [ [[package]] name = "zip" -version = "9.1.0" +version = "9.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2886b00003a72c4ccee3f42cfc34908d33b2508a74d827dc2a4244706475c704" +checksum = "1156b39d142a34b999c2310a50455e0cb3760bf7efad0122f796d813bee26ccb" dependencies = [ "crc32fast", "flate2", diff --git a/Cargo.toml b/Cargo.toml index 8eb38b29af..7be5624db7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ exclude = ["vendor"] default-members = ["crates/*", "apps/pdfcraft", "apps/pdfcraft-cli", "xtask"] [workspace.package] -version = "0.14.0" +version = "0.18.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" @@ -13,51 +13,52 @@ repository = "https://github.com/storytold/pdfcraft" [workspace.dependencies] # Shared frontend components; engines remain independent of egui. -craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "edc41f68fa24c46651c3b5117d10f0ead75ec9cb", features = ["serde"] } +craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff", features = ["serde"] } # Quit the macOS way (#1136): ⌘Q asks about unsaved changes, and closing quits through `terminate:`. -craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "edc41f68fa24c46651c3b5117d10f0ead75ec9cb" } +craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" } # One menu definition for the native macOS menu bar and the in-window bar elsewhere. -craft-menu = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "edc41f68fa24c46651c3b5117d10f0ead75ec9cb" } -craft-keymap = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "edc41f68fa24c46651c3b5117d10f0ead75ec9cb", default-features = false } +craft-menu = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" } +craft-keymap = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff", default-features = false } # Shared system spooler; document composition remains in pdfcraft-print. -craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "42e30cd5b3f4b114270dd053fccbc33bedc47f68" } +craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" } # Whether a path stored in a document may be opened (GoToR link targets). -craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" } -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", features = ["egui"] } +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" } +# craft-update: app-specific pretend versions apply to startup and manual checks. +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff", features = ["egui"] } # internal -pdfcraft-geom = { path = "crates/geom", version = "0.14.0" } -pdfcraft-filters = { path = "crates/filters", version = "0.14.0" } -pdfcraft-cos = { path = "crates/cos", version = "0.14.0" } -pdfcraft-crypt = { path = "crates/crypt", version = "0.14.0" } -pdfcraft-organize = { path = "crates/organize", version = "0.14.0" } -pdfcraft-measure = { path = "crates/measure", version = "0.14.0" } -pdfcraft-annot = { path = "crates/annot", version = "0.14.0" } -pdfcraft-audit = { path = "crates/audit", version = "0.14.0" } -pdfcraft-fonts = { path = "crates/fonts", version = "0.14.0" } -pdfcraft-model = { path = "crates/model", version = "0.14.0" } -pdfcraft-content = { path = "crates/content", version = "0.14.0" } -pdfcraft-redact = { path = "crates/redact", version = "0.14.0" } -pdfcraft-print = { path = "crates/print", version = "0.14.0" } -pdfcraft-xfdf = { path = "crates/xfdf", version = "0.14.0" } -pdfcraft-sign = { path = "crates/sign", version = "0.14.0" } -pdfcraft-optimize = { path = "crates/optimize", version = "0.14.0" } -pdfcraft-a11y = { path = "crates/a11y", version = "0.14.0" } -pdfcraft-oracle = { path = "crates/oracle", version = "0.14.0" } -pdfcraft-testkit = { path = "crates/testkit", version = "0.14.0" } -pdfcraft-ocr = { path = "crates/ocr", version = "0.14.0" } -pdfcraft-js = { path = "crates/js", version = "0.14.0" } -pdfcraft-compare = { path = "crates/compare", version = "0.14.0" } -pdfcraft-preflight = { path = "crates/preflight", version = "0.14.0" } -pdfcraft-export = { path = "crates/export", version = "0.14.0" } -pdfcraft-edit = { path = "crates/edit", version = "0.14.0" } -pdfcraft-create = { path = "crates/create", version = "0.14.0" } -pdfcraft-forms = { path = "crates/forms", version = "0.14.0" } -pdfcraft-xfa = { path = "crates/xfa", version = "0.14.0" } -pdfcraft-render = { path = "crates/render", version = "0.14.0" } -pdfcraft-engine = { path = "crates/engine", version = "0.14.0" } -pdfcraft-platform = { path = "crates/platform", version = "0.14.0" } -pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.14.0" } -pdfcraft-automation = { path = "crates/automation", version = "0.14.0", default-features = false } +pdfcraft-geom = { path = "crates/geom", version = "0.18.0" } +pdfcraft-filters = { path = "crates/filters", version = "0.18.0" } +pdfcraft-cos = { path = "crates/cos", version = "0.18.0" } +pdfcraft-crypt = { path = "crates/crypt", version = "0.18.0" } +pdfcraft-organize = { path = "crates/organize", version = "0.18.0" } +pdfcraft-measure = { path = "crates/measure", version = "0.18.0" } +pdfcraft-annot = { path = "crates/annot", version = "0.18.0" } +pdfcraft-audit = { path = "crates/audit", version = "0.18.0" } +pdfcraft-fonts = { path = "crates/fonts", version = "0.18.0" } +pdfcraft-model = { path = "crates/model", version = "0.18.0" } +pdfcraft-content = { path = "crates/content", version = "0.18.0" } +pdfcraft-redact = { path = "crates/redact", version = "0.18.0" } +pdfcraft-print = { path = "crates/print", version = "0.18.0" } +pdfcraft-xfdf = { path = "crates/xfdf", version = "0.18.0" } +pdfcraft-sign = { path = "crates/sign", version = "0.18.0" } +pdfcraft-optimize = { path = "crates/optimize", version = "0.18.0" } +pdfcraft-a11y = { path = "crates/a11y", version = "0.18.0" } +pdfcraft-oracle = { path = "crates/oracle", version = "0.18.0" } +pdfcraft-testkit = { path = "crates/testkit", version = "0.18.0" } +pdfcraft-ocr = { path = "crates/ocr", version = "0.18.0" } +pdfcraft-js = { path = "crates/js", version = "0.18.0" } +pdfcraft-compare = { path = "crates/compare", version = "0.18.0" } +pdfcraft-preflight = { path = "crates/preflight", version = "0.18.0" } +pdfcraft-export = { path = "crates/export", version = "0.18.0" } +pdfcraft-edit = { path = "crates/edit", version = "0.18.0" } +pdfcraft-create = { path = "crates/create", version = "0.18.0" } +pdfcraft-forms = { path = "crates/forms", version = "0.18.0" } +pdfcraft-xfa = { path = "crates/xfa", version = "0.18.0" } +pdfcraft-render = { path = "crates/render", version = "0.18.0" } +pdfcraft-engine = { path = "crates/engine", version = "0.18.0" } +pdfcraft-platform = { path = "crates/platform", version = "0.18.0" } +pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.18.0" } +pdfcraft-automation = { path = "crates/automation", version = "0.18.0", default-features = false } # pdf hayro = "0.7.1" @@ -65,6 +66,11 @@ hayro-syntax = "0.7.2" lopdf = "0.45" flate2 = { version = "1.1", default-features = false, features = ["rust_backend"] } weezl = "0.2" +# CCITT Group 4 encoding (pdfcraft-filters); already in the tree through tiff. +fax = "0.2.7" +# Independent CCITT and JBIG2 codecs, already in the tree through hayro. +hayro-ccitt = "0.3" +hayro-jbig2 = "0.3" # foundation thiserror = "2" @@ -91,6 +97,9 @@ web-time = "1.1" unsafe_code = "forbid" [workspace.lints.clippy] +cognitive_complexity = "deny" +too_many_arguments = "deny" +wildcard_imports = "deny" dbg_macro = "warn" todo = "warn" @@ -125,6 +134,7 @@ hayro-postscript = { path = "vendor/hayro-postscript" } hayro-jbig2 = { path = "vendor/hayro-jbig2" } lopdf = { path = "vendor/lopdf" } vello_common = { path = "vendor/vello_common" } +vello_cpu = { path = "vendor/vello_cpu" } # The egui and winit crates below are pinned to immutable storytold git revisions (egui # 6d518fc799681571963842f95b55a6b68431b15a, winit a517b3846ee8457d84ee4975110bfda6797c44dc). # Only the retained `vendor/winit` and `vendor/egui-wgpu` copies are provenance for the app's diff --git a/LICENSES.toml b/LICENSES.toml new file mode 100644 index 0000000000..d9ceb3180e --- /dev/null +++ b/LICENSES.toml @@ -0,0 +1,159 @@ +# Stage 1 source documents only. Destinations are logical names, not packaging instructions. +# licences lists audited full-text identifiers; omission grants no bundle applicability. + +[[document]] +source = "LICENSE-APACHE" +licences = ["Apache-2.0"] +destination = "LICENSE-APACHE" +sha256 = "0d542e0c8804e39aa7f37eb00da5a762149dc682d7829451287e11b938e94594" + +[[document]] +source = "LICENSE-MIT" +licences = ["MIT"] +destination = "LICENSE-MIT" +sha256 = "cd7032ea33b71427f188e34ffb4aee9d6868a57fd196037c5c8b2be03a2a0352" + +[[document]] +source = "NOTICE" +destination = "NOTICE" +sha256 = "9f854f730719af962cd840529324d089e05e68a30bd245c50e33f96e009b4314" + +[[document]] +source = "assets/app-icon/LICENSE-document.txt" +destination = "assets/app-icon/LICENSE-document.txt" +sha256 = "64a3137e3d5cc41e5b78d2ad8dd9ddddd6588283f9f7b3128aa6a8a50852fb9a" + +[[document]] +source = "assets/app-icon/LICENSE.txt" +destination = "assets/app-icon/LICENSE.txt" +sha256 = "b768acafe6cbad8247eedf86b5b5381feefcd4ee0f8c7808b86811bc687cf37b" + +[[document]] +source = "assets/fonts/OFL-DancingScript.txt" +destination = "assets/fonts/OFL-DancingScript.txt" +sha256 = "6f090277c00af96651ce6dbcc38ff1591047a3bffef486e80b6a32e8276a8201" + +[[document]] +source = "assets/fonts/OFL-Inter.txt" +destination = "assets/fonts/OFL-Inter.txt" +sha256 = "262481e844521b326f5ecd053e59b98c8b2da78c8ee1bdbb6e8174305e54935a" + +[[document]] +source = "assets/fonts/OFL-JetBrainsMono.txt" +destination = "assets/fonts/OFL-JetBrainsMono.txt" +sha256 = "a76abf002c49097d146e86740a3105a5d00450b1592e820a1109a8c5680cd697" + +[[document]] +source = "assets/icons/LICENSE-lucide.txt" +destination = "assets/icons/LICENSE-lucide.txt" +sha256 = "b495047bd93a9b06913511076f504daba17d5bbeb3e0650f3bb53a4220329c57" + +[[document]] +source = "docs/brand/LICENSE-brand.txt" +destination = "docs/brand/LICENSE-brand.txt" +sha256 = "0f33f48e7a96cd359d4e4e5b11424b13cebeb59cc70c77928186695e4173585c" + +[[document]] +source = "licenses/CMap-LICENSE.txt" +licences = ["BSD-3-Clause"] +destination = "licenses/CMap-LICENSE.txt" +sha256 = "742665db9c8e1bc72603c6d319ca3e90b83bd6d95202f0cd4ef11068a07a9c29" +origin = "https://static.crates.io/crates/hayro-cmap/hayro-cmap-0.1.0.crate#assets/LICENSE.txt" + +[[document]] +source = "licenses/egui-Hack-Regular.txt" +licences = ["MIT", "Bitstream-Vera"] +destination = "licenses/egui-Hack-Regular.txt" +sha256 = "47c0cccbeec7e8614548cc485588b28149e7874188df5f41b36efebcee285c87" +origin = "https://raw.githubusercontent.com/emilk/egui/0.36.2/crates/epaint_default_fonts/fonts/Hack-Regular.txt" + +[[document]] +source = "licenses/egui-OFL.txt" +licences = ["OFL-1.1"] +destination = "licenses/egui-OFL.txt" +sha256 = "6a73f9541c2de74158c0e7cf6b0a58ef774f5a780bf191f2d7ec9cc53efe2bf2" +origin = "https://raw.githubusercontent.com/emilk/egui/0.36.2/crates/epaint_default_fonts/fonts/OFL.txt" + +[[document]] +source = "licenses/egui-UFL.txt" +licences = ["Ubuntu-font-1.0"] +destination = "licenses/egui-UFL.txt" +sha256 = "2f0015108d68627bd788d313f529c21ff4da2c2c42a5e1f3883acc83480f9002" +origin = "https://raw.githubusercontent.com/emilk/egui/0.36.2/crates/epaint_default_fonts/fonts/UFL.txt" + +[[document]] +source = "licenses/egui-emoji-icon-font-mit-license.txt" +licences = ["MIT"] +destination = "licenses/egui-emoji-icon-font-mit-license.txt" +sha256 = "b9d2c1d909aa149996fd4c91dcb92b2362a04431640c1d200959da94caf8cde1" +origin = "https://raw.githubusercontent.com/emilk/egui/0.36.2/crates/epaint_default_fonts/fonts/emoji-icon-font-mit-license.txt" + +[[document]] +source = "vendor/hayro-interpret/LICENSE-APACHE" +licences = ["Apache-2.0"] +destination = "vendor/hayro-interpret/LICENSE-APACHE" +sha256 = "0cec06e0e55fbc3dc5cee4fca9b607f66cb8f4e4dbcf3b3c013594dd156732e9" + +[[document]] +source = "vendor/hayro-interpret/LICENSE-MIT" +licences = ["MIT"] +destination = "vendor/hayro-interpret/LICENSE-MIT" +sha256 = "51b50e6120c344dcc146bbd6375ca1c78e079d97c8bea76e67a53d6ff0efebac" + +[[document]] +source = "vendor/hayro-interpret/assets/CGATS_LICENSE.txt" +destination = "vendor/hayro-interpret/assets/CGATS_LICENSE.txt" +sha256 = "469d87b5fa48f8aaf69e25f4fcac3a309c97dbb7d561b917638abdb640e004d9" + +[[document]] +source = "vendor/hayro-interpret/assets/LICENSE_FOXIT" +destination = "vendor/hayro-interpret/assets/LICENSE_FOXIT" +sha256 = "b578cdd2345840ada550bd12519533812320d5f1d21cf4c1c7e1b1b0a31c98b7" + +[[document]] +source = "vendor/hayro-cmap/assets/LICENSE.txt" +licences = ["BSD-3-Clause"] +destination = "vendor/hayro-cmap/assets/LICENSE.txt" +sha256 = "742665db9c8e1bc72603c6d319ca3e90b83bd6d95202f0cd4ef11068a07a9c29" + +[[document]] +source = "vendor/vello_common/LICENSE-MIT" +licences = ["MIT"] +destination = "vendor/vello_common/LICENSE-MIT" +sha256 = "adf157330f722fe7cfd5964a2a6974eff851b718228c6735724a11706e6f4dec" + +[[bundled]] +crate = "epaint_default_fonts" +path = "fonts/Ubuntu-Light.ttf" +licence = "Ubuntu-font-1.0" +documents = ["licenses/egui-UFL.txt"] + +[[bundled]] +crate = "epaint_default_fonts" +path = "fonts/Hack-Regular.ttf" +licence = "MIT AND Bitstream-Vera" +documents = ["licenses/egui-Hack-Regular.txt"] + +[[bundled]] +crate = "epaint_default_fonts" +path = "fonts/NotoEmoji-Regular.ttf" +licence = "OFL-1.1" +documents = ["licenses/egui-OFL.txt"] + +[[bundled]] +crate = "epaint_default_fonts" +path = "fonts/emoji-icon-font.ttf" +licence = "MIT" +documents = ["licenses/egui-emoji-icon-font-mit-license.txt"] + +[[bundled]] +crate = "epaint_default_fonts" +path = "fonts/egui-icons.ttf" +licence = "MIT" +documents = ["licenses/egui-emoji-icon-font-mit-license.txt"] + +[[bundled]] +crate = "hayro-interpret" +path = "src/font/generated/metrics.rs" +licence = "MIT OR Apache-2.0" +documents = ["vendor/hayro-interpret/LICENSE-MIT", "vendor/hayro-interpret/LICENSE-APACHE"] diff --git a/NOTICE b/NOTICE index 63a8fe672c..e8f31ade2f 100644 --- a/NOTICE +++ b/NOTICE @@ -12,7 +12,7 @@ Third-party material This product bundles third-party material under its own licenses, listed below. The per-file list (author, source, license) is in ATTRIBUTION.md. -- Lucide icons (assets/icons/*.svg) — ISC License. See assets/icons/LICENSE-lucide.txt. +- Lucide icons (assets/icons/*.svg) — ISC and MIT licences (Feather-derived icons). See assets/icons/LICENSE-lucide.txt. The Windows PDF file icon (assets/app-icon/pdfcraft-document.svg and .ico) adapts Lucide's file-text outline and combines it with contributor-original artwork. See assets/app-icon/LICENSE-document.txt (ISC AND (MIT OR Apache-2.0)), packaged as diff --git a/README.md b/README.md index f319ffeadb..3c1b67936a 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@

- PdfCraft icon: an engraved lion on a teal tile + PdfCraft icon: an engraved lion on a red tile

PdfCraft

@@ -21,10 +21,10 @@

- License: MIT OR Apache-2.0 - Written in Rust - Platforms: macOS, Windows, Linux, FreeBSD, web - No account, no telemetry + License: MIT OR Apache-2.0 + Written in Rust + Platforms: macOS, Windows, Linux, FreeBSD, web + No account, no telemetry
Scanned by SonarCloud Reviewed by CodeRabbit @@ -140,6 +140,8 @@ Search the whole document as you type, step through matches with ⌘G ## Navigate long documents +Launch with `pdfcraft --two-paged file.pdf` (or `pdfcraft file.pdf --two-paged`) to open each command-line PDF in noncontinuous two-page view. This optional launch override does not change saved default-layout preferences or PDFs restored from the previous session. An explicit `--layout` option takes precedence on the active document regardless of argument order; `--default-layout` retains its existing behavior for subsequently opened documents. + Right-click a PDF tab to close it, reopen the last closed file-backed tab, or reveal its file. Closing still asks about unsaved changes. Reopening reads the saved file from disk; it does not restore discarded edits. Reveal selects the file in Explorer or Finder. On Linux and FreeBSD it requests `org.freedesktop.FileManager1.ShowItems`, falling back to opening the containing directory with `xdg-open` when that service is unavailable. Reopen and Reveal are unavailable on the web; Reveal is also unavailable for pathless documents. Bookmarks, page thumbnails and the document's own page labels (i, ii, 1, 2…) keep you oriented in long documents. Search bookmark titles in the Bookmarks panel to find nested entries even when their parents are collapsed. Matches keep their ancestors for context; Clear restores the unfiltered tree without changing its expansion state. @@ -459,11 +461,13 @@ The portable zip runs from any folder, a USB stick included. Its `portable.txt` logs and crash recovery in a `PdfCraftData` folder next to `pdfcraft.exe`, so nothing is written to `%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" +The MSI installs for your account only by default, without administrator privileges. Choose +"Everyone on this computer" on its first page, or pass `ALLUSERS=1` from an elevated terminal, to install +for all users. It adds "Edit with PdfCraft" 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: +without a desktop shortcut (add `ALLUSERS=1`, from an elevated terminal, for an all-users install): ```powershell msiexec /i "pdfcraft--windows-x64.msi" /qn /norestart INSTALLDESKTOPSHORTCUT=0 @@ -471,7 +475,7 @@ msiexec /i "pdfcraft--windows-x64.msi" /qn /norestart INSTALLDESKTOPSHORTCU | Property | `=0` leaves out | |---|---| -| `INSTALLDESKTOPSHORTCUT` | the all-users desktop shortcut | +| `INSTALLDESKTOPSHORTCUT` | the desktop shortcut | | `INSTALLPDFCONTEXTMENU` | "Edit with PdfCraft" on PDF files | | `INSTALLIMAGECONTEXTMENU` | "Create PDF with PdfCraft…" on images | @@ -543,9 +547,18 @@ stand on its own. | | **VectorCraft** | Vector illustration | [GitHub](https://github.com/storytold/vectorcraft) | [Website](https://getartcraft.com/apps/vectorcraft) | | | **FilmCraft** | Video editing, color and sound | [GitHub](https://github.com/storytold/filmcraft) | [Website](https://getartcraft.com/apps/filmcraft) | | | **LightCraft** | Photo library and raw development | [GitHub](https://github.com/storytold/lightcraft) | [Website](https://getartcraft.com/apps/lightcraft) | -| | **PdfCraft** | **Reading, organizing and protecting PDFs · you are here** | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) | +| | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) | | | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) | | | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) | +| | **FigCraft** | Interface design and prototyping | [GitHub](https://github.com/storytold/figcraft) | [Website](https://getartcraft.com/apps/figcraft) | +| | **WordCraft** | Word processing and documents | [GitHub](https://github.com/storytold/wordcraft) | [Website](https://getartcraft.com/apps/wordcraft) | +| | **SoundCraft** | Audio recording and editing | [GitHub](https://github.com/storytold/soundcraft) | [Website](https://getartcraft.com/apps/soundcraft) | +| | **DeckCraft** | Presentations and slide decks | [GitHub](https://github.com/storytold/deckcraft) | [Website](https://getartcraft.com/apps/deckcraft) | +| | **CADCraft** | Computer-aided design | [GitHub](https://github.com/storytold/cadcraft) | [Website](https://getartcraft.com/apps/cadcraft) | +| | **GridCraft** | Spreadsheets and data | [GitHub](https://github.com/storytold/gridcraft) | [Website](https://getartcraft.com/apps/gridcraft) | +| | **TextureCraft** | 3D texture painting | [GitHub](https://github.com/storytold/texturecraft) | [Website](https://getartcraft.com/apps/texturecraft) | +| | **Craft Anything** | See how any software works, then build your own | [GitHub](https://github.com/storytold/craft-anything) | [Website](https://getartcraft.com/apps/craft-anything) | +| | **SolidCraft** | Parametric 3D CAD by Tangency Labs (community app) | [GitHub](https://github.com/tangency-labs/solidcraft) | | And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video studio for artists who want real control. diff --git a/ROADMAP.md b/ROADMAP.md index 165fccc5e8..5bfe1fc817 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,7 +1,30 @@ # PdfCraft roadmap +> **Last reviewed:** 2026-11-03 | **Last updated:** 2026-11-03 | **Change:** patch (M5 #5850 review: indirect typewriter intent and composer edits preserve outer whitespace; movement regression commits by outside click; estimates unchanged) + +> **Last reviewed:** 2026-11-03 | **Last updated:** 2026-11-03 | **Change:** patch (M5 #5850: Enter adds Fill & Sign line breaks; outside clicks commit, Escape cancels, and saved multiline appearances retain their text and height; estimates unchanged) + +> **Last reviewed:** 2026-11-01 | **Last updated:** 2026-11-01 | **Change:** patch (M7, PR #2155 further review: inherited drawing/text state, affine image gestures, paste/selection ownership, no-op scripts and menu permissions; estimates unchanged) + +> **Last reviewed:** 2026-11-01 | **Last updated:** 2026-11-01 | **Change:** patch (M7, PR #2155 latest comments: explicit image-paste events, mixed-size field refresh, and documented mixed-script normalization with Undo; estimates unchanged) + +> **Last reviewed:** 2026-11-01 | **Last updated:** 2026-11-01 | **Change:** patch (M7, PR #2155 review follow-up: preserve image crop/tag/stream ownership, resize in page orientation, harden clipboard and text formatting, and reload installed fonts; estimates unchanged) + +> **Last reviewed:** 2026-10-31 | **Last updated:** 2026-10-31 | **Change:** patch (M2, PR #4172: GBK rasters compared with explicit Unicode and independently selected installed faces; wrong-glyph and wrong-family negative controls fail; estimates unchanged) +> **Last reviewed:** 2026-10-31 | **Last updated:** 2026-10-31 | **Change:** patch (M2, PR #4172: integrate main fa5f9f8c; retain CJK viewer and upstream raster regressions, restore explicit display_config imports; estimates unchanged) +> **Last reviewed:** 2026-10-28 · **Last updated:** 2026-10-28 · **Change:** patch (M2, PR #4172: integrate main 5af10276; retain both roadmap histories and viewer-only CJK font policy; estimates unchanged) +> **Last reviewed:** 2026-10-27 · **Last updated:** 2026-10-27 · **Change:** patch (M2, PR #4172: integrate main 1dd2737b; preserve viewer-only CJK fallback and regression coverage across engine, renderer and Combine module splits; estimates unchanged) +> **Last reviewed:** 2026-10-27 · **Last updated:** 2026-10-27 · **Change:** patch (M2, PR #4172: integrate main 9a9813bf and preserve viewer-only CJK fallback through the engine module split; estimates unchanged) +> **Last reviewed:** 2026-10-26 · **Last updated:** 2026-10-26 · **Change:** patch (M2, PR #4172: document installed-font resolution helpers and regression contracts; behavior and estimates unchanged) +> **Last reviewed:** 2026-10-26 · **Last updated:** 2026-10-26 · **Change:** patch (M2, PR #4172: print preview excludes installed CJK fonts and isolates its font cache from the viewer; estimates unchanged) +> **Last reviewed:** 2026-10-26 · **Last updated:** 2026-10-26 · **Change:** patch (M2, PR #4172: reconcile current main while retaining display-only CJK fallback and upstream Japanese font selection; estimates unchanged) +> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M2, PR #4172: extend on-screen CJK fallback to Combine thumbnails and figure previews; recognise FangSong and subset-prefixed Founder names; estimates unchanged) +> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (typed small Edit font sizes and Fill & Sign text/date colour, #2444/#2496; estimates unchanged) + **Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away +> **Last reviewed:** 2026-10-29 | **Last updated:** 2026-10-29 | **Change:** minor (M7 #2517: a narrowed then widened paragraph keeps the typed spaces the wrap consumed, after save and reopen; estimates unchanged) +> **Last reviewed:** 2026-10-27 | **Last updated:** 2026-10-27 | **Change:** minor (M10: OCR model downloads use verified ONNX pairs with legacy discovery fallback; estimates unchanged) > **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (M14 localization current state: Arabic logical catalog, explicit RTL label direction and mirrored stock chrome; native/browser RTL workflows unqualified. Native release Hans face via LXGW WenKai Lite; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M7, #2636 partial: pasting added text boxes and images in the engine and through `content_copy`; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M11: image optimization accounts for page-local UserUnit; estimates unchanged) @@ -20,6 +43,7 @@ > **Last reviewed:** 2026-10-20 | **Last updated:** 2026-10-20 | **Change:** minor (M2, #3574: named form properties retain page origin and own dictionary boundaries; estimates unchanged) > **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** minor (M4: initial-view options resolve indirect boolean preferences; estimates unchanged) > **Last reviewed:** 2026-10-16; **Last updated:** 2026-10-16; **Change:** minor (M5 #2669, item 5: reply groups and page root counts per frame; estimates unchanged) +> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M7 #2517: a partial rewrite keeps its marker around a kept row, and off the next marked region; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M7: the Edit text caret is as tall as the row it is on; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: CSV tab-containing headers; estimates unchanged) > **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M3, #3149: discard render demand after page geometry changes and check the GPU texture limit before upload; estimates unchanged) @@ -94,8 +118,8 @@ and add a line to the [progress log](#progress-log); when a gap closes, remove i | | Value | Kind | |---|---|---| -| **Feature breadth** | **≈ 67%** tier-weighted (56.9% unweighted: 423 shipped + 74 partial of 808; P0 94.2%) | measured, `parity/acrobat-features.toml` | -| Feature breadth, mainstream practitioner | ≈ 70% tier-weighted (63.2% unweighted; P0 95.3%) | measured ([split](docs/target-app-parity.md#mainstream-practitioner-vs-full-acrobat-pro)) | +| **Feature breadth** | **≈ 67%** tier-weighted (57.0% unweighted: 423 shipped + 75 partial of 808; P0 94.2%) | measured, `parity/acrobat-features.toml` | +| Feature breadth, mainstream practitioner | ≈ 70% tier-weighted (63.3% unweighted; P0 95.3%) | measured ([split](docs/target-app-parity.md#mainstream-practitioner-vs-full-acrobat-pro)) | | **Ready for real work**, full Acrobat Pro | **≈ 50%** (45–53%) | estimated, weighted sum over [dimensions](#by-dimension) | | **Ready for real work**, mainstream practitioner | **≈ 55%** (50–60%) | estimated ([split](docs/target-app-parity.md#mainstream-practitioner-vs-full-acrobat-pro)) | | **Ready for real work**, essentials user | **≈ 66%** (60–70%) | estimated ([method](docs/target-app-parity.md#essentials-user)) | @@ -150,7 +174,7 @@ Hours are calibrated as in [Effort and calibration](docs/target-app-parity.md#ef | H Digital signatures | 62.2% | 45% | 40–70 | | I Scan and OCR | 23.1% | 15% | 40–75 | | J Create and export | 39.7% | 25% | 55–100 | -| K Optimize and standards | 34.5% | 20% | 65–115 | +| K Optimize and standards | 35.3% | 20% | 65–115 | | L Print | 39.7% | 35% | 30–55 | | M Accessibility | 63.1% | 30% | 40–70 | | N Automation, preferences, misc | 30.0% | 35% | 25–45 | @@ -199,10 +223,54 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log +- **2026-11-03 (M5, #5850 review):** Resolve indirect IT when recognizing typewriter contents and Fill & Sign marks. Comment listing and property lookup retain their existing direct-Subtype behavior. A real double-click Edit composer preserves the typewriter’s text as written; ordinary comment edits keep trimming. Regressions cover direct Subtype with indirect intent, save/reopen, raw posted contents and same-index comments on different pages. The existing live-movement test now commits its setup with an outside click rather than Enter. Estimates unchanged. + +- **2026-11-03 (M5, #5850):** Fill & Sign Enter inserts a line break in a growing editor; clicking outside commits and Escape cancels. Nonempty text keeps leading, internal and trailing line breaks. The headless fill_sign_add path sizes its typewriter box for the same wrapped lines so saved appearances show every row. Production-order keyboard and engine save/reopen/render regressions exercise the UI and headless callers. Estimates unchanged. + +- **2026-11-03 (M7, #5835):** In image crop mode, a right-click on the image, its crop box or its handles keeps the crop and opens no menu, so the menu no longer switches as the pointer moves. A right-click elsewhere clears the selection and opens the page menu. UI tests cover all four page rotations, overlapping images, a 45° image, and a check that the Done/Cancel/Remove Crop pills at a page edge still keep the crop. + +- **2026-11-02 (M10, #5743):** macOS print jobs name their title and owner (`com.apple.print.JobInfo.PMJobName`, `PMJobOwner`), as the system print dialog does, because a Kyocera filter crashes on a job without them. Checked by a test and through `doc_print` against a stand-in `lp`; not yet on a physical printer. + +- **2026-11-01 (M7, #5657):** Header/footer, watermark and background additions and replacements stage the complete call before committing. Synthetic later-page failures preserve exact incremental bytes, object count, dirty state and allocation cursor; success controls retain other marks and shared/inherited resources. Estimates unchanged. + +- **2026-11-01 (M14, #5572):** Preferences ▸ Documents and view sets how many recent files to keep, from 0 to 50 with 12 by default. At 0 Home has no Recent section and File ▸ Open Recent is disabled. The list is cut when Preferences closes, so the number can pass through 0 on its way to another. + +- **2026-11-01 (M5/M14, customizable hotbar, freehand highlight, and moveable navigation rail):** Added Freehand-Highlight (`/Subtype /Ink`, `/IT /FreehandHighlight`, Multiply blending, 100 pt max line width) inside the standard Highlight flyout and headless `comment_add`; added a customizable floating or side-docked hotbar with a full-side chrome strip mode, settings gear icon anchored at the bar end, and 24–64 px sizing; and added Left/Right docking for the navigation rail (`rail_side`). Focused unit and integration tests pass across `annot`, `automation`, and `ui-egui`. Estimates unchanged. +- 2026-11-01: PR #2155 further review preserves inherited Form text state and soft-mask chronology during Arrange, composes crop-group moves in the proper scope, and uses affine/press-relative image resize plus a shared rotation pivot. No-op script buttons preserve hidden source text; paste coordinates, visible selection ownership, and Snapshot/Export permissions have dedicated regressions. Candidate only; estimates unchanged. + +- 2026-11-01: PR #2155 latest follow-up handles explicit image-paste events without release-time guesses, refreshes the font-size field when a selection becomes mixed, and specifies mixed-script normalization with exact Undo restoration. Candidate only; estimates unchanged. + +- 2026-11-01: PR #2155 review follow-up preserves editor crops through group moves and keeps arranged artwork outside added-item streams; unsafe nested-tag extraction is refused. Image handles and flips follow page orientation, added images accept general transforms, and clipboard/text/Home/font-discovery edge cases have focused regressions. Candidate only; estimates unchanged. +- 2026-11-01: PR #2155 maintainer edits remove redundant image/text helpers and add regressions for crop ownership, arrange scope/state fidelity, clipboard precedence, crop-drag bounds, and font scanning. Transform and Unicode-script tools complete the headless paths. + +- **2026-10-18 (M3, #1354, local candidate):** Integrated --two-paged with current main, retaining upstream modal/picker ownership and single-tab reuse. Native launch/opening/password regressions, negative checks against the old wrapper, visual inspection, formatting, strict scoped Clippy, layers, assets, parity and scoped WASM compilation pass. Three existing socket tests are unavailable in this workspace; one existing network test remains ignored. No push or GitHub merge; estimates unchanged. +- **2026-10-31 (M2, PR #4172 review):** Added same-machine pixel oracles for GBK Song, Hei, Kai and FangSong, including all four subset-prefixed Founder aliases. Reference PDFs use explicit Unicode mappings and independently selected installed faces; font data and rasters remain in memory. All eight cases ran; wrong-glyph and wrong-family production mutations each failed the new assertion and were restored. Validation: 422 renderer unit tests, renderer all-target Clippy with -D warnings, formatting, layers/assets/parity and renderer WASM checks passed; existing optional-patch/WASM warnings and parity notes remain. Estimates unchanged. + +- **2026-10-31 (M2, PR #4172):** Integrated main fa5f9f8c, retaining both roadmap histories and raster regression groups. Restored display_config and thumbnail_config imports after the upstream explicit-import migration. Validation: 422 renderer unit tests, engine/UI all-target checks, scoped all-target Clippy with -D warnings, formatting, layers, assets, parity and renderer/engine WASM checks passed. Existing optional-patch/WASM warnings and parity notes remain; no full engine/UI runtime test rerun. Display-only font policy and estimates unchanged. + +- **2026-10-28 (M2, PR #4172):** Integrated main 5af10276, preserving both roadmap histories and reviewing auto-merged export tests and accessibility UI changes. Validation: 488 scoped tests passed (18 engine, 412 renderer unit, 43 renderer integration, 15 Combine; one existing renderer integration test ignored); scoped all-target Clippy with -D warnings, formatting, layers, assets, parity and renderer/engine WASM checks passed. Viewer-only CJK fallback and print/output font policy remain unchanged; estimates unchanged. + +- **2026-10-27 (M2, PR #4172 module-split conflicts):** Integrated main 1dd2737b, retaining viewer-only CJK fallback in split session modules and Combine thumbnail workers, and moving renderer regressions to the new test module. Validation: 798 scoped tests passed (371 engine, 412 renderer, 15 Combine; two existing engine tests ignored); scoped all-target Clippy with -D warnings, formatting, layers, assets, parity and renderer/engine WASM checks passed. Estimates unchanged. + +- **2026-10-27 (M10, OCR models):** Model staging downloads and verifies the ONNX pair after the upstream RTEN URL brownout; runtime discovery prefers complete ONNX pairs per directory, retains complete RTEN fallback and preserves directory precedence. + +- **2026-10-26 (M2, PR #4172):** Reconciled current main, preserving its Japanese serif/weight selection and both branches' progress records. Installed CJK viewer and preview fallback remains display-only; estimates unchanged. + +- **2026-10-29 (M7, #2517):** Widening a paragraph rejoins a wrap with the number of spaces that wrap consumed, including a typed run and none for a Chinese or Japanese word split. The count survives an incremental save and a full rewrite, and a width-only `text_edit` uses it. Estimates unchanged. +- **2026-10-26 (M11, JBIG2 symbols):** Lossless JBIG2 now stores each distinct mark of a black-and-white image once, as a symbol, and places its exact copies, when that is smaller than the generic region: on six pages of rendered 300 dpi text 28% of the generic region's size and 17% of Group 4's, and the same bits back through two independent decoders (hayro-jbig2 and Poppler). Scans, whose letters are seldom identical, gain little until refinement coding; a dictionary shared between pages (`/JBIG2Globals`) is next. Estimates unchanged. +- **2026-10-25 (M5, comment replies):** One reply can be deleted from the Comments panel without its thread. On the selected card each reply has its own "…" menu (Edit, Copy text, Delete); Delete takes that reply with its pop-up and the replies to it, as `comment_delete` already did headlessly, and the comment and its other replies stay. The card stays selected, and the panel's editor stays open, on what they were on though the delete moved it in `/Annots`. Undo and Redo close the editor, since either can move what it was open on. A locked reply can't be deleted. Estimates unchanged. - **2026-10-24 (M10, #4158 partial):** Word, HTML, RTF and PowerPoint export keep a block that continues another on its line (an italic case name, a link, a superscript) as a run in its own style inside that paragraph, with the source's word space, instead of a paragraph of its own. Style changes that wrap onto the next line still split paragraphs (the reading blocks keep style boundaries). Estimates unchanged. - **2026-10-24 (M3, window move on Wayland, #3970 follow-up):** After a move or resize by PdfCraft's own title bar or edges, the button release given back to egui goes before the pointer leaving the window, so hover no longer sticks at the old place until the pointer moves. Found by CodeRabbit in review of #3970; a unit test covers it. Estimates unchanged. - **2026-10-24 (M14, #4253):** The annotation quick bar remembers Keep tool selected across restarts, including unpinning. Old and malformed settings keep safe defaults; the opt-in UI control channel reads and changes the same preference. Estimates unchanged. - +- **2026-10-22 (M7, #2517):** A partial rewrite of the last paragraph closes its marker after that paragraph's last `ET`, so a row left in the source stays in the paragraph. When the next paragraph shares the text object, the marker closes before that neighbour instead of after the shared `ET`. The extra marker is not added across two marked-content regions. Estimates unchanged. +- **2026-10-15 (M7, #2517):** A blank line between paragraphs stays a paragraph break. An explicit `TL` is the measured baseline gap. Without one, three or more rows at a repeated step stay one paragraph, and a single wider gap stays a break. Adding a row, even when an earlier row is left in the source, does not absorb the next paragraph on a later edit. Estimates unchanged. + +- **2026-10-24 (M11, more black-and-white images):** The optimizer re-stores Group 3 fax images as Group 4 when that is smaller, instead of leaving any CCITT image it doesn't resample as it is. Black-and-white images stored as eight-bit RGB (as some phone and scanner apps write them) become one-bit grey, and indexed images that use only their first two palette entries become one bit with their palette, so Monochrome Images compresses and downsamples them too. +- **2026-10-24 (M11, monochrome downsampling):** The PDF Optimizer and Reduce File Size average black-and-white images above 450 ppi down to 300 ppi (Monochrome Images ▸ Downsample to), Group 4 scans included, so a 600 ppi text page goes from 79 KB to 40 KB. CCITT decoding moved into `pdfcraft-filters` (`decode_ccitt`), shared by Export ▸ Images and the optimizer through `Document::decoded_image`. +- **2026-10-24 (M11, black-and-white pictures):** A scan saved as a 1-bit PNG became an eight-bit grey image when made into a PDF, larger than the PNG; Create PDF now stores pictures of only black and white at one bit (Group 4 when smaller), the optimizer packs eight-bit black-and-white grey images to one bit unless the grey settings make them smaller, as they can a dithered scan, soft mask counted, and Export ▸ Images reads CCITT fax images. +- **2026-10-23 (M11, JBIG2):** The PDF Optimizer can store black-and-white images as lossless JBIG2 (a generic region, written from the JBIG2 draft standard and checked against its arithmetic-coder test sequence), about a third smaller than Group 4 on 300 dpi text, and Export ▸ Images reads JBIG2 images. JBIG2 symbol coding, where its largest gains on text are, is next. +- **2026-10-23 (M11, monochrome compression):** The PDF Optimizer and Reduce File Size store black-and-white images (scans, image masks) losslessly as CCITT Group 4 when that is smaller, with a Monochrome Images setting in the Optimizer and `doc_optimize` `mono`. JBIG2 and monochrome downsampling are next. +- **2026-10-23 (M6, calculation checks):** The calculation order now flags a formula that reads a field the form doesn't have (a field deleted from under a sum, a typo in notation, a script's `getField`) and a script that builds the names it reads at run time. Prepare a form ▸ Calculation order marks both on the row and in a card that names the broken formulas, in place of its all-clear. `form_calculation_order` gives each field's formula, and `form_fields` shows a custom Calculate script. - **2026-10-23 (M7, #4280):** Edit text names each standard stand-in font after its face (`PCEdTimesBold`, ...) instead of one shared `PCEdHelv`. Rewriting a bold paragraph whose subset lacks an accent no longer repoints the paragraphs set earlier in a regular stand-in at Times-Bold, and the editor's frame stays on the paragraph being edited. Pages saved by older builds keep their shared name and are not repaired. Estimates unchanged. - **2026-10-23 (M8, #3205 follow-up):** Phone-pattern redaction matches complete labelled Italian geographic numbers such as `Tel. +39 0426/123456`, with synthetic matcher and mark/apply/save/reopen coverage; unlabelled international slash numbers remain unsupported. - **2026-10-23 (M7, #2636 partial):** A new `PasteContent` edit pastes an added text box or image copied in the same document or another, keeping its style, picture, turns, flips and crop, as one undo step; `content_copy` drives it headlessly. A paste lands beside earlier copies (18 pt steps) and stays on the page. A pasted image reuses its picture object in its own document and after the first paste into another; there a colour profile is dropped and CMYK colours stored losslessly are turned to RGB. The desktop keys and menus follow in separate PRs. Estimates unchanged. @@ -218,8 +286,11 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md The regression checks two distinct appearances, inherited resources, print/reopen pixels and unchanged source data. Estimates unchanged. - **2026-10-21 (M3, window resize and move on Wayland):** With PdfCraft's own title bar on GNOME/Wayland, a second resize by a window edge, or a second move by the empty tab strip, works straight after the first; it waited for a click inside the window, because the compositor keeps the button's release from egui. A unit test covers the release given back; checked by hand in a test build. Estimates unchanged. +- **2026-10-24 (M5, move preview):** A comment being moved is drawn where it goes while it is dragged (a rectangle or oval showed only its blue frame): the comment is rendered alone once as the drag starts and laid over the page, which shows it where it was until the move is applied. Engine `Document::comment_layer`; synthetic engine and UI regressions. Estimates unchanged. - **2026-10-22 (M4, #3916 partial):** Document edits and Undo/Redo cancel an Organize page drag. Pointer-release regressions check exact page order and save/reopen. - **2026-10-22 (M10, #3752 paper):** CUPS jobs ask for the chosen paper in every size. The 13 sizes the Print dialog sent no paper for (A6, A0–A2, ARCH, ANSI C–E) use PWG 5101.1 names, checked against a synthetic PPD with `cupsfilter`, and `doc_print` to a printer sends the paper too. The macOS tray half (Auto Select instead of the PPD's default tray) is a craft-print change that needs a pin bump here. Estimates unchanged. + +- **2026-10-23 (M2, #234, continuation of #1567):** Reconciled installed CJK viewer fallback with current rendering changes; Windows preserves installed Song/Hei/Kai families and bounds font reads. GBK-EUC-H CIDFontType2 regression coverage includes text extraction; viewer fallback also survives reload with retained preferences. System fonts remain display-only. No overall readiness estimate changed. - **2026-10-22 (M14, macOS association recovery candidate):** Successful desktop open/save/sign can queue recovery of an existing per-file binding to the running PDFCraft bundle through NSWorkspace. Metadata-only watcher changes retain undo and avoid false overwrite prompts; real same-length/same-mtime edits remain protected. Rebased on 0.12.0 and reuses #3042's shared atomic metadata preservation; the bounded recovery parser does not narrow save limits. Prior native Preview TEST-signature and Finder workflows passed on macOS 27.0.1; other versions remain unqualified. Estimates unchanged. - **2026-10-13 (M7, free image rotation):** A selected page image has a rotate knob (Shift: 15 degree steps) and `image_edit` a `rotate_by` action; the angle is written as one undoable `cm`. Snapping to page edges while moving is in #1740; resize snapping is not done. Estimates unchanged. @@ -298,6 +369,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-19 (M3, #3360):** Switching between Read and the docking workspace retains the current document’s scroll position at fixed zoom, including within-page position; separate tabs retain their own offsets. Estimates unchanged. - **2026-10-18 (M8, #1350):** Integrated current main while preserving content-stream comment boundaries. Hidden-text cleanup retires unused named replacement descriptions on rewritten pages and forms, including inherited/shared resource holders, without removing descriptions still used by surviving content. Synthetic saved-byte and preservation regressions cover the cleanup. Estimates unchanged. - **2026-10-17 (M4, #2997):** Dragging page thumbnails in the Organize grid with Ctrl held (Option on macOS) drops copies at the gap instead of moving the pages: a "+" badge sits by the pointer, the copies land at the gap selected, and one undo step ("Duplicate pages") takes them out again. `page_duplicate` takes `at` (1-based, where the first copy goes; left out, after the last of them) so agents can drive the same thing, and the UI control channel's `ui.drag` modifiers now reach gestures that read the input state. Estimates unchanged. +- **2026-10-16 (M7, Add Background):** A background added behind a page that paints its own opaque white backdrop (browsers' Save as PDF, print drivers) was hidden completely, so the tool looked broken. The dialog now detects such pages and offers to replace the backdrop (on by default): the one fill becomes `n` in a per-page copy of its stream, and Remove background turns the fill back on, after edits and redactions too, unless the operators before it changed or it would no longer be painted first; then it says which pages keep it off. The copy's tag holds no reference to the original stream, which had kept text a redaction or Remove Hidden Information removed in the saved file. Scanned pages are warned about, not touched. New `doc_backdrops` tool; `doc_background` takes `replace_backdrop`. The default colour is a visible light yellow and the preview shows text over it. Acrobat's own behaviour on such pages is unobserved. Estimates unchanged. - **2026-10-15 (M6, AcroForm XML text):** Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Synthetic regressions verify exact values and unknown catalog data after incremental and full save/reopen. Nested-element and XFA interpretation are unchanged. Estimates unchanged. - **2026-10-15 (M6, #1917 refresh):** Integrated upstream `99941daa` while retaining the XML/XFDF attribute whitespace correction and all upstream progress. Only independent documentation conflicts required resolution; focused production and test files are unchanged from the previously green CI head. Estimates unchanged. - **2026-10-15 (M1, #2267 refresh):** Integrated upstream `99941daa` while retaining the raw-deflate fallback correction and all upstream progress. Only independent documentation conflicts required resolution; focused production and test files are unchanged from the previously green CI head. Estimates unchanged. @@ -338,6 +410,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **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-12 (M10, Edit on a scanned page):** Turning Edit on (its tab, Edit text & images or Add text) for a scanned page (an image covering at least half of a page with no text of its own; comments and text boxes over it do not count) recognizes the page first, like Acrobat's "Converting scanned page to Editable Text and Images", so its words open for editing. Once per page per window, one undoable "Recognize text" step, with the language of the Recognize Text dialog (English only for now, gap 7). Pages with real text (a flyer with a background picture), read-only files and other tools are left alone. The photographs on a scan are still part of the one page image. + +- **2026-10-12 (M2, installed CJK fonts):** A CID font of an Adobe CJK collection (Japan1, GB1, CNS1, Korea1) that a PDF doesn't embed, such as the Kozuka Gothic of an Acrobat text box or the SongtiTC of a Pages export, drew nothing unless craft-fonts had a Japanese face. After craft-fonts, it now draws with a face already installed on the machine (PingFang, Heiti, Microsoft JhengHei / YaHei, Malgun and the like; read at runtime, never bundled, and used only for the on-screen viewer, never for exports, printing or OCR; Noto CJK and Source Han excluded; `PDFCRAFT_SYSTEM_FONTS=0` turns it off). The face of a collection with several regions (PingFang SC, TC) is chosen by region. Checked on macOS with a synthetic Traditional Chinese, Simplified Chinese and Korean page; the Windows and Linux font lists are untested, and a machine with none of the listed faces still draws nothing. - **2026-10-13 (M3, typed zoom):** The right rail's zoom percentage is an editable field: type 150, 150% or 62.5 % and press Enter, Escape discards, and nonsense keeps the current zoom. The control channel's zoom option shares its parser, which now refuses zero, negative and non-finite values. - **2026-10-13 (M4, indirect page rotation):** Rotation queries and edits resolve indirect integer values on pages and their page-tree ancestors. Turning a page already at 270° by 90° now saves it at 0°, including when its rotation is inherited. Synthetic full/incremental save-and-reopen tests cover nested inheritance, sibling preservation, negative/large angles and invalid-value fallback. Estimates unchanged. - **2026-10-14 (M1, Flate fallback):** Raw deflate whose first bytes resemble a zlib header no longer loses a decoding-limit error during fallback. Strict and tolerant filter regressions and a COS stream regression cover refusal at the caller limit and successful decoding within it. No parity status or estimate changes. @@ -447,6 +521,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md Newest first: the date, what moved, and the new overall estimate. +- **2026-10-28 (M10, #1595):** Export to Markdown: `doc_export_office` with a `.md` or `.markdown` path writes CommonMark with GitHub pipe tables (headings, paragraphs with bold and italic, tables with their first row as the header, a rule between pages); text that Markdown would read as markup is escaped. Images, lists and links are not in the export's layout model and are left out. No menu item yet. Estimates unchanged. - **2026-10-12 (M3, GNOME title bar, #553):** On GNOME's Wayland session, whose compositor draws no window frames (it offers no `zxdg_decoration_manager_v1`), PdfCraft no longer shows winit's fallback title bar: it turns the frame off and the tab strip becomes the header bar, as in GNOME's own apps. Minimize, maximize/restore and close are drawn in Adwaita's style, in the order and on the side GNOME's `button-layout` names (Fedora's default shows only close); the window's edges and corners resize it, and a hairline outlines it while it isn't maximized. Preferences ▸ Use system title bar (shown only there) gives the window back winit's frame at once, without a restart (part of #1001), and `PDFCRAFT_TITLEBAR=app|system` overrides the choice. KDE, Sway, Hyprland, COSMIC and X11 keep the frame their desktop draws. Not done: right-clicking the header bar doesn't open GNOME's window menu (egui can't ask for it), the window has no shadow or rounded corners, the resize edges lie just inside the window rather than outside it, and Windows still uses its system frame (the rest of #1001). Estimates unchanged. - **2026-10-12 (M0, build label):** A local or patched build can carry a label, set at compile time with `PDFCRAFT_BUILD_LABEL`: `pdfcraft --version`, `pdfcraft-cli --version`, About and the log show `0.7.1 (titlebar)`, and `packaging/linux/package.sh` names the files `pdfcraft-0.7.1-titlebar-…` and makes the .rpm/.deb release `1.titlebar`, which upgrades over the release of the same version (and the next release over it). The version itself is unchanged, so the update check still compares it with published releases. Unlabelled builds are as before. Estimates unchanged. - **2026-10-11 (M6, #1208):** Dropdown and list-box Field Properties expose the existing Left/Center/Right alignment picker. Existing alignment stays intact until changed; cancel, undo/redo and save/reopen are covered through the real UI without changing options, values or flags. Estimates unchanged. @@ -509,10 +584,10 @@ Newest first: the date, what moved, and the new overall estimate. - **2026-10-10 (L Print, #756):** Windows prints. The Print dialog lists the system's printers and its default from `Win32_Printer` (through PowerShell's CIM; `wmic` is gone from Windows 11), and Print spools the print-ready PDF: the in-box Windows.Data.Pdf renders each sheet at the printer's own resolution and System.Drawing.Printing carries copies, collation, duplex and colour as the job's settings — both reached through `powershell`, so the Rust code touches no Win32 API and the machine needs no PDF reader. This closes #756 and the "printer not showing" duplicates #160, #244, #293, #465 and #644, using the CIM approach community-tested in #452; the tests render the job's sheets through the whole WinRT path on CI (dry run, no printer needed) and, where a printer can be added, catch a real job in its paused queue. Web printing is still missing; milestone and effort estimates are unchanged. - **2026-10-10 (M3, #740):** Column select, as in Acrobat: holding Alt (Option on macOS) when a drag starts selects only the text inside the rectangle, with the Select tool, the text markup tools and Redact; Edit ▸ Column select does it with a plain drag, for Linux desktops whose window manager takes Alt-drag (Xfce, Cinnamon, MATE, KDE Plasma 5 by default). Copy gives rows top to bottom with tab-separated cells (right to left in right-to-left rows, reading order for vertical text); `text_extract` takes the same rectangle as `rect`. The new label is in all 15 catalogs. Also repaired `parity/acrobat-features.toml`, which #650 had left unparseable (duplicate keys on `form.field-list`). Still missing: column selection across pages and ⇧-click extension. Estimates unchanged. -- **2026-10-10 (progress docs, hours per audience):** Each readiness number gets its own hours to ≈ 95%: full ≈ 49% / 780–1,400 h, mainstream ≈ 55% / 500–900 h, essentials ≈ 66% / 180–330 h. The full number is now the standard's additive weighted sum over the dimensions (written weights), 47% → 49%: method aligned with the standard, no new evidence. Beta ≈ 26 points, ≈ 400–700 h. +- **2026-10-10 (progress docs, hours per audience):** Each readiness number gets its own hours to ≈ 95%: full ≈ 49% / 780–1,400 h, mainstream ≈ 55% / 500–900 h, essentials ≈ 66% / 180–330 h. The full number is now the additive weighted sum over the dimensions (written weights), 47% → 49%; no new evidence. Beta ≈ 26 points, ≈ 400–700 h. - **2026-10-10 (progress docs, gate consistency check):** Re-judged the convert-and-share row with the rule applied across all Crafting Apps (a workflow blocks only when it can't be completed at all; lossy exchange and missing sub-cases are beta items, as for FilmCraft's .prproj or VectorCraft's .ai). Unencrypted sharing works, encryption interop (#774) and Office→PDF are sub-cases, Word export is lossy rather than absent: partial, not blocking. **Stage pre-alpha → alpha**; numbers unchanged (47%, mainstream 55%; beta ≈ 400–700 h). Added an essentials-user score: ≈ 66% for someone who only opens, reads, comments, fills, signs, prints and rearranges pages. -- **2026-10-10 (progress docs, second look and alpha gate):** At the owner's request, after practitioners praised PdfCraft, re-examined the numbers. User sentiment: 7,595 stars, 177k + 39k downloads of 0.4.0/0.5.0, 23 user issues with explicit praise and switching reports (#179, #232, #273), issue themes concentrated in core paths (launch/GPU, rendering, editing, CJK, signing, printing). The checklist is not niche-dominated (497 mainstream vs 311 specialist rows; P0 95% / 91%). The first pass's 9-point cross-cutting deduction double-counted Windows printing, interop and glyphs: **ready for real work 40% → 47%**, and **≈ 55% for the mainstream practitioner** (new). The new core-workflow gate (craftrules progress-docs) fails on convert and share (#774, no Office import, #773): **stage alpha → pre-alpha**, ≈ 40–70 h from alpha; beta still ≈ 400–700 h. -- **2026-10-10 (progress docs, full re-measure):** Re-measured against Acrobat Pro 26.002.21931 (installed bundle inspected: document types, localizations, plug-in and framework names). Checklist: 423 shipped + 74 partial of 808 (56.9%; P0 94.2%), ≈ 67% tier-weighted. Ready for real work ≈ 40%; stage **alpha**; ≈ 400–700 h to beta, ≈ 780–1,400 h to full parity (up from 600–1,100 h: the first public releases surfaced Acrobat interop of encrypted output, Windows printing, Chinese UI glyphs, Word-export layout and a 245-issue backlog). This file restructured to the craftrules progress-docs standard; the honest assessment moved to `docs/target-app-parity.md`, the gap list to `docs/gaps.md`, the milestones (duplicated rows merged) to `docs/roadmap.md`; new `docs/architecture.md`, `docs/localization-parity.md`, `docs/file-format-parity.md`, `docs/pdf-spec-parity.md`, `docs/ui-parity.md`, `docs/hardware-parity.md`. +- **2026-10-10 (progress docs, second look and alpha gate):** At the owner's request, after practitioners praised PdfCraft, re-examined the numbers. User sentiment: 7,595 stars, 177k + 39k downloads of 0.4.0/0.5.0, 23 user issues with explicit praise and switching reports (#179, #232, #273), issue themes concentrated in core paths (launch/GPU, rendering, editing, CJK, signing, printing). The checklist is not niche-dominated (497 mainstream vs 311 specialist rows; P0 95% / 91%). The first pass's 9-point cross-cutting deduction double-counted Windows printing, interop and glyphs: **ready for real work 40% → 47%**, and **≈ 55% for the mainstream practitioner** (new). The new core-workflow gate fails on convert and share (#774, no Office import, #773): **stage alpha → pre-alpha**, ≈ 40–70 h from alpha; beta still ≈ 400–700 h. +- **2026-10-10 (progress docs, full re-measure):** Re-measured against Acrobat Pro 26.002.21931 (installed bundle inspected: document types, localizations, plug-in and framework names). Checklist: 423 shipped + 74 partial of 808 (56.9%; P0 94.2%), ≈ 67% tier-weighted. Ready for real work ≈ 40%; stage **alpha**; ≈ 400–700 h to beta, ≈ 780–1,400 h to full parity (up from 600–1,100 h: the first public releases surfaced Acrobat interop of encrypted output, Windows printing, Chinese UI glyphs, Word-export layout and a 245-issue backlog). This file restructured; the honest assessment moved to `docs/target-app-parity.md`, the gap list to `docs/gaps.md`, the milestones (duplicated rows merged) to `docs/roadmap.md`; new `docs/architecture.md`, `docs/localization-parity.md`, `docs/file-format-parity.md`, `docs/pdf-spec-parity.md`, `docs/ui-parity.md`, `docs/hardware-parity.md`. - **2026-10-10 (M8, redact word lists):** Find text and redact gains "Multiple words or phrases": a list with one word or phrase per line, typed or imported from a `.txt` file, whose every match is marked in one undo step. Lists are trimmed, deduplicated and bounded (1,000 entries of 256 characters, files up to 1 MB). `redact_mark` takes `words` and reports how many marks each made, so an agent sees which found nothing. Tests cover the list parsing, the tool end to end (mark, undo, apply, invalid lists) and the dialog with an imported list; the new labels are translated in all 13 UI catalogs. `protect.redact-word-lists` is shipped; whole-word and case-sensitive matching and exporting the list are still missing. Estimates unchanged (about 30–35%). - **2026-10-10 (M4, bookmarks from structure):** Bookmark options ▸ New bookmarks from structure (command `bookmark.from_structure`, tool `bookmark_from_structure`) turns a tagged document's headings into bookmarks: H1–H6 through the role map, a plain H by its section nesting, nested by level under a new first "Untitled" bookmark as in Acrobat, each linked to its structure element (/SE). Titles come from /ActualText, the marked text on the page (decoded through the font's ToUnicode or encoding), /Alt or /T. The walk is bounded (depth, visits, heading and title caps, cycle-safe), an untagged document gets a clear error, and the edit is one undo step. Tests cover role maps, nesting, ToUnicode text, a self-referencing heading, the tool, undo and the panel. Still missing: Acrobat's element-type choice and destinations at the heading's position. The M4 row no longer lists Replace pages as missing (it shipped earlier). Overall estimate unchanged (about 30–35%). - **2026-10-10 (M9, signing encrypted documents):** Digital signatures, certification and document timestamps work on password-protected PDFs instead of failing with "not supported: signing encrypted documents". `pdfcraft-cos` no longer encrypts or decrypts a signature dictionary's `/Contents` (§7.6.2; by `/Type /Sig|/DocTimeStamp` or `/Contents` + `/ByteRange`), so the placeholder is patched after hashing as in a plain file and existing signatures in encrypted files read their real CMS (which also fixes their DSS `/VRI` keys and keeps them intact in a full rewrite). Opened with the user password, the signer applies the document's permissions (fill-forms for existing fields and timestamps, annotate + modify for new fields, modify for certifying) and names the permissions password when it refuses. Synthetic tests cover all four algorithms, counter-signing, tampering, permissions and the automation tools. Overall estimate unchanged (about 30–35%). @@ -682,6 +757,11 @@ Newest first: the date, what moved, and the new overall estimate. | Date | Change | Summary | |---|---|---| +| 2026-11-03 | patch | M5 #5850 review: indirect typewriter intent and composer edits preserve outer whitespace; movement regression commits by outside click; estimates unchanged. | +| 2026-11-03 | patch | M5 #5850: Enter adds Fill & Sign line breaks; outside clicks commit, Escape cancels, and saved multiline appearances retain their text and height; estimates unchanged. | +| 2026-11-01 | patch | M7 #5657: header/footer, watermark and background operations commit all selected pages together; later-page errors preserve marks, content, shared/inherited resources and pending edits; estimates unchanged. | +| 2026-10-28 | patch | M2, PR #4172: integrate main 5af10276 and retain both roadmap histories; viewer-only CJK and print/output font policies and estimates unchanged. | +| 2026-10-27 | minor | M10: OCR stages SHA-256-verified ONNX models after the upstream RTEN URL brownout; discovery prefers a complete ONNX pair per directory, falls back to a complete RTEN pair, and never mixes formats or directories; language/web limits and estimates unchanged. | | 2026-10-23 | minor | M8, #3205 follow-up: phone-pattern redaction includes the +39 country code on labelled Italian geographic numbers with a slash; dates, protocol identifiers and whole-run guards remain. Estimates unchanged. | | 2026-10-23 | minor | M11: image downsampling measures physical resolution after page-local UserUnit scaling, including shared nested forms; synthetic measurement and optimization regressions. Estimates unchanged. | | 2026-10-23 | minor | M10: Word table cells use the grid columns they span, including cells after a merged cell; synthetic export regression and CLI reproduction. Estimates unchanged. | @@ -719,11 +799,11 @@ Newest first: the date, what moved, and the new overall estimate. | 2026-10-11 | minor | M7: #594 paragraph-drag candidate and source regressions prepared; subsequent Linux validation passed all eight CI gates. Estimates unchanged. | | 2026-10-10 | minor | Recorded the remaining System appearance repair after #881; estimates unchanged | | 2026-10-11 | minor | Contributor fork: document-font selection and subset completion, CFF, style ranges, contextual/bidi save/reopen and all-page Chinese keyboard acceptance; curve/hole hit testing (shared clips stay fixed, as on main). Scope remains bounded; percentages unchanged | -| 2026-10-10 | minor | Readiness table with hours per audience; full number restated as the additive weighted sum over dimensions (47% → 49%; method aligned with the standard, no new evidence) | +| 2026-10-10 | minor | Readiness table with hours per audience; full number restated as the additive weighted sum over dimensions (47% → 49%; no new evidence) | | 2026-10-10 | minor | Essentials-user score added (≈ 66%) | | 2026-10-10 | minor | Gate consistency check: convert and share re-judged partial, not blocking; stage pre-alpha → alpha | | 2026-10-10 | minor | Second look: ready 40% → 47% (cross-cutting double count removed), mainstream-practitioner numbers and user sentiment added; core-workflow alpha gate applied, stage alpha → pre-alpha | -| 2026-10-10 | major | Restructured to the progress-docs standard (stage, two numbers, dimensions, features, languages, upcoming, log); full re-measure; assessment, gaps and milestones moved to `docs/`; log sorted newest first and a stray merged entry dated | +| 2026-10-10 | major | Progress docs restructured (stage, two numbers, dimensions, features, languages, upcoming, log); full re-measure; assessment, gaps and milestones moved to `docs/`; log sorted newest first and a stray merged entry dated | | 2026-10-07 | minor | 806 tracked features, 51.0% shipped, effort-weighted ≈ 30–35%, 600–1,100 h | | 2026-10-05 | major | Honest assessment and "Where we're lacking" added | | 2026-10-12 | minor | M8: cleanup removes replacement descriptions only from changed marked-content spans, preserving surviving accessibility text. Estimates unchanged. | @@ -745,3 +825,19 @@ Newest first: the date, what moved, and the new overall estimate. - **2026-10-18 (renderer, hayro patches 19/35):** A mesh shading (types 4–7) is sampled over its own triangles instead of the whole path: a BricsCAD plan with 310 one-triangle `sh` fills took 2 s per 1024 px tile at every zoom (and hit the 20 s watchdog on a busy machine); now about 70 ms, so zooming into dense topology drawings stays fluid. Estimates unchanged. - **2026-10-22 (PR #3227 integration):** M11: integrate upstream `80b7d215`; retain partial target-version compatibility and upstream content-boundary and image-size regression coverage. Scope and estimates unchanged. + +- **2026-10-23 (M2, PR #4172):** Combine thumbnails and accessibility figure previews use the viewer's display-only CJK fallback. Windows family matching includes FangSong and subset-prefixed Founder names. Export, print, OCR and automation remain off by default; estimates unchanged. + +- **2026-10-26 (M2, PR #4172 print-preview review):** Print-preview requests disable installed CJK fallback and use an isolated cache so preview font availability matches output. Viewer fallback survives switching in either direction. Synthetic GBK regressions cover inline and single-worker rendering, full pages and tiles; estimates unchanged. + +- **2026-10-26 (M2, PR #4172 documentation review):** Added function documentation for installed-font script selection, bounded candidate reads, platform paths, resolver precedence and regression contracts. No runtime behavior or estimate change. + +- **2026-10-27 (M2, PR #4172 conflict resolution):** Integrated main `9a9813bf` and its engine module split. Opening, layer changes and session renderer rebuilds retain display-only CJK fallback; output configuration and print-preview cache isolation are preserved. Validation passed: 16 scoped engine regressions, 412 renderer unit tests, 43 renderer integration tests (one existing corpus case ignored), 15 Combine tests, scoped Clippy, formatting, layers/assets/parity and renderer/engine WASM checks. Estimates unchanged. +## Pages sidebar keyboard refresh (2026-10-30) + +PR #2133 retains document-local Pages sidebar focus for Ctrl/Cmd+A and Escape after the UI module split. The selection does not navigate. The focused regression is in `crates/ui-egui/tests/it/view.rs`; current-head hosted validation is pending. Feature counts and estimates are unchanged. +## Button export-value refresh (2026-10-30) + +PR #1952 retains per-widget checkbox/radio export-value editing in the split form-authoring and Prepare modules. Caption editing remains supported. Tests cover preserved appearances and selection, invalid/locked refusals, data-format round trips, and Apply/Cancel/Undo. Current-head hosted validation is pending; feature estimates are unchanged. + +- **2026-10-31 (#1577):** Reconciled the page-shadow preference with split viewer, organizer, settings and control modules, preserving upstream page-gap shadow suppression. CI validation pending. diff --git a/apps/pdfcraft-cli/src/check.rs b/apps/pdfcraft-cli/src/check.rs new file mode 100644 index 0000000000..62993ad6dc --- /dev/null +++ b/apps/pdfcraft-cli/src/check.rs @@ -0,0 +1,208 @@ +use super::commands::{parse_dpi, read}; +use super::options::{AUDIT_OPTIONS, CHECK_ONE_OPTIONS, CHECK_OPTIONS, flag, positional, validate_options}; +use super::{ + Arc, CliError, Command, Duration, Instant, PageRenderer, Path, PathBuf, RenderConfig, RenderRequest, RequestKind, Stdio, inspect, stdout_line, +}; + +/// Audit a document for faux redactions. Prints the findings as JSON; fails when any are +/// found so scripts and CI can gate on it, and when the audit itself cannot run. +pub(crate) fn audit(args: &[String]) -> Result<(), CliError> { + validate_options("audit", args, AUDIT_OPTIONS)?; + let path = *positional(args, AUDIT_OPTIONS).first().ok_or("audit: missing file")?; + let bytes = read(path)?; + let password = flag(args, "--password"); + let doc = pdfcraft_cos::Document::open_with_password(bytes.clone(), password).map_err(|e| format!("{path}: {e}"))?; + let findings = pdfcraft_audit::audit_redactions(&doc).map_err(|e| format!("{path}: {e}"))?; + // Rectangles in points from the top-left of the displayed page, as `doc_audit_redactions` + // reports them; the page geometry is read only when there is something to place. + let info = if findings.is_empty() { + None + } else { + let render_password = pdfcraft_engine::render_password_for(bytes.clone(), password); + Some(inspect(bytes, render_password.as_deref()).map_err(|e| format!("{path}: {e}"))?) + }; + let items: Vec = findings + .into_iter() + .map(|f| { + let rect = f.rect.map(|v| v as f32); + serde_json::json!({ + "page": f.page + 1, + "kind": f.kind.id(), + "rect": info.as_ref().map_or(rect, |i| pdfcraft_automation::view_rect(i, f.page, rect)), + "covered_text": f.covered_text, + }) + }) + .collect(); + println!("{}", serde_json::json!({ "file": path, "findings": items, "count": items.len() })); + if items.is_empty() { Ok(()) } else { Err(format!("audit: {} faux redaction(s) found", items.len()).into()) } +} + +/// Child-process body for `check`: prints one JSON line. +pub(crate) fn check_one(args: &[String]) -> Result<(), CliError> { + validate_options("check-one", args, CHECK_ONE_OPTIONS)?; + let path = *positional(args, CHECK_ONE_OPTIONS).first().ok_or("check-one: missing file")?; + let dpi = parse_dpi(flag(args, "--dpi").unwrap_or("36"))?; + let start = Instant::now(); + let bytes = read(path)?; + let (status, pages, failed, detail, warnings) = match inspect(bytes.clone(), None) { + Err(e) => (format!("open-error:{}", short(&e.to_string())), 0, 0, e.to_string(), 0), + Ok(info) => { + let mut r = PageRenderer::new(bytes, RenderConfig::default()); + let mut failed = Vec::new(); + for i in 0..info.pages.len().min(500) { + let p = r.render(RenderRequest { page: i, kind: RequestKind::Pixels, tile: None, scale: dpi / 72.0, tag: 0, preview: false }); + if let Some(e) = r.render(RenderRequest { page: i, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0, preview: false }).error { + failed.push(format!("p{} text: {e}", i + 1)); + } + if let Some(e) = p.error { + failed.push(format!("p{}: {e}", i + 1)); + } + } + let status = if failed.is_empty() { "ok".to_string() } else { "page-errors".to_string() }; + (status, info.pages.len(), failed.len(), failed.join(" | "), info.warnings.len()) + } + }; + // `--edit` (fuzzing): also run our own object layer end to end, with no panic safety net, + // so a panic in cos shows up as a crash of this process. + if args.iter().any(|a| a == "--edit") { + edit_round_trip(&std::fs::read(path).map_err(|e| e.to_string())?); + } + let line = serde_json::json!({ "file": path, "status": status, "pages": pages, "failed_pages": failed, "warnings": warnings, + "ms": start.elapsed().as_millis() as u64, "detail": detail.chars().take(400).collect::() }); + stdout_line(format_args!("{line}"))?; + Ok(()) +} + +/// Open with cos, touch every object, edit the catalog, save incrementally and in full (both +/// output styles), and reopen each result. Errors are fine; panics, hangs and aborts are bugs. +fn edit_round_trip(bytes: &[u8]) { + use pdfcraft_cos::{Document, Object, SaveOptions, write_full, write_incremental}; + let Ok(mut doc) = Document::open(Arc::new(bytes.to_vec())) else { return }; + for num in doc.object_numbers().into_iter().take(20_000) { + if let Ok(o) = doc.try_get(num) + && let Object::Stream(s) = &*o + { + let _ = s.decoded(); + } + } + if let Some(root) = doc.root() { + let _ = doc.update_dict(root, |d| d.set(b"PdfCraftFuzz".to_vec(), Object::Bool(true))); + } + let classic = SaveOptions { object_streams: false, ..SaveOptions::default() }; + for out in [write_incremental(&doc, &SaveOptions::default()), write_full(&doc, &SaveOptions::default()), write_full(&doc, &classic)] + .into_iter() + .flatten() + { + if let Ok(again) = Document::open(Arc::new(out)) { + let _ = again.root().map(|r| again.get(r)); + } + } +} + +fn short(s: &str) -> String { + s.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).take(4).collect::>().join("-").to_lowercase() +} + +fn collect(paths: &[&str]) -> Vec { + let mut out = Vec::new(); + let mut stack: Vec = paths.iter().map(PathBuf::from).collect(); + let mut visited = std::collections::HashSet::new(); + while let Some(p) = stack.pop() { + if p.is_dir() { + // Directory aliases and symlink cycles must not scan the same tree repeatedly. + let identity = match p.canonicalize() { + Ok(identity) => identity, + Err(error) => { + eprintln!("{}: could not resolve directory: {error}", p.display()); + continue; + } + }; + if !visited.insert(identity) { + continue; + } + if let Ok(rd) = std::fs::read_dir(&p) { + stack.extend(rd.flatten().map(|e| e.path())); + } + } else if p.extension().and_then(std::ffi::OsStr::to_str).is_some_and(|e| e.eq_ignore_ascii_case("pdf")) { + out.push(p); + } + } + out.sort(); + out +} + +pub(crate) fn check(args: &[String]) -> Result<(), CliError> { + validate_options("check", args, CHECK_OPTIONS)?; + // Every option is checked before the inputs are scanned, so a bad value is one message, + // not one problem per file: a zero timeout used to make every file time out, and a bad + // dpi used to reach each child, which printed nothing and was reported as a crash. + let timeout_value = flag(args, "--timeout").unwrap_or("20"); + let timeout = Duration::from_secs( + timeout_value + .parse::() + .ok() + .filter(|secs| *secs > 0) + .ok_or_else(|| format!("bad --timeout: expected a positive whole number of seconds, got {timeout_value:?}"))?, + ); + let dpi = flag(args, "--dpi").unwrap_or("36").to_string(); + parse_dpi(&dpi)?; + let files = collect(&positional(args, CHECK_OPTIONS)); + if files.is_empty() { + return Err("check: no PDF files found".into()); + } + let exe = std::env::current_exe().map_err(|e| e.to_string())?; + let mut results = Vec::new(); + let mut counts: std::collections::BTreeMap = Default::default(); + for (n, f) in files.iter().enumerate() { + let r = run_child(&exe, f, &dpi, timeout); + let status = r["status"].as_str().unwrap_or("?").to_string(); + *counts.entry(status.split(':').next().unwrap_or("?").to_string()).or_default() += 1; + if status != "ok" { + eprintln!( + "[{}/{}] {status:<28} {} {}", + n + 1, + files.len(), + f.display(), + r["detail"].as_str().unwrap_or("").chars().take(160).collect::() + ); + } + results.push(r); + } + eprintln!("\nchecked {} files: {:?}", files.len(), counts); + if let Some(out) = flag(args, "--json") { + std::fs::write(out, serde_json::to_string_pretty(&results).unwrap_or_default()).map_err(|e| e.to_string())?; + } + Ok(()) +} + +fn run_child(exe: &Path, file: &Path, dpi: &str, timeout: Duration) -> serde_json::Value { + let file_s = file.to_string_lossy().to_string(); + let child = Command::new(exe).args(["check-one", &file_s, "--dpi", dpi]).stdout(Stdio::piped()).stderr(Stdio::null()).spawn(); + let mut child = match child { + Ok(c) => c, + Err(e) => return serde_json::json!({ "file": file_s, "status": "harness-error", "detail": e.to_string() }), + }; + let start = Instant::now(); + loop { + match child.try_wait() { + Ok(Some(status)) => { + let mut out = String::new(); + if let Some(mut so) = child.stdout.take() { + use std::io::Read; + let _ = so.read_to_string(&mut out); + } + return match serde_json::from_str::(out.trim()) { + Ok(v) => v, + Err(_) => serde_json::json!({ "file": file_s, "status": "crash", "detail": format!("child exited with {status}") }), + }; + } + Ok(None) if start.elapsed() > timeout => { + let _ = child.kill(); + let _ = child.wait(); + return serde_json::json!({ "file": file_s, "status": "timeout", "detail": format!("exceeded {}s", timeout.as_secs()) }); + } + Ok(None) => std::thread::sleep(Duration::from_millis(20)), + Err(e) => return serde_json::json!({ "file": file_s, "status": "harness-error", "detail": e.to_string() }), + } + } +} diff --git a/apps/pdfcraft-cli/src/commands.rs b/apps/pdfcraft-cli/src/commands.rs new file mode 100644 index 0000000000..9d0b2057f0 --- /dev/null +++ b/apps/pdfcraft-cli/src/commands.rs @@ -0,0 +1,413 @@ +use super::options::{ + COMBINE_OPTIONS, EXTRACT_OPTIONS, INFO_OPTIONS, RENDER_OPTIONS, SPLIT_OPTIONS, TEXT_OPTIONS, can_be_path_value, flag, needs_value, positional, + validate_options, +}; +use super::{Arc, CliError, ImageFormat, PageRenderer, Path, PathBuf, RenderConfig, RenderRequest, RequestKind, Write, inspect, stdout_line}; + +pub(crate) fn read(path: &str) -> Result>, String> { + std::fs::read(path).map(Arc::new).map_err(|e| format!("{path}: {e}")) +} + +pub(crate) fn info(args: &[String]) -> Result<(), CliError> { + validate_options("info", args, INFO_OPTIONS)?; + let path = *positional(args, INFO_OPTIONS).first().ok_or("info: missing file")?; + let bytes = read(path)?; + let password = flag(args, "--password"); + // The renderer takes only the user password of R2–R4 files; `Session::open` takes either. + let render_password = pdfcraft_engine::render_password_for(bytes.clone(), password); + let mut info = inspect(bytes.clone(), render_password.as_deref()).map_err(|e| e.to_string())?; + let pdf_pages = info.pages.len(); + let acroform_fields = info.fields.len(); + // Dynamic XFA forms: say what laying the template out gives (what the app shows). + let xfa_layout = (info.xfa == Some(pdfcraft_render::Xfa::Dynamic)) + .then(|| { + let mut s = pdfcraft_engine::Session::new(); + let id = s.open("info.pdf", None, bytes.clone(), password).ok()?; + let d = s.get(id)?; + d.xfa.as_ref().map(|x| { + info = d.info.clone(); + // The in-memory layout is an appended revision, not the file being inspected. + info.file_size = bytes.len(); + serde_json::json!({ "pages": x.pages, "fields": x.fields, "warnings": x.warnings }) + }) + }) + .flatten(); + // What the document declares as a standard, read the way the Standards panel reads it, so + // `info` and the app agree. A file that declares nothing leaves these null and empty. + let declared = + pdfcraft_cos::Document::open_with_password(bytes.clone(), password).map(|doc| pdfcraft_preflight::declared(&doc)).unwrap_or_default(); + let json = serde_json::json!({ + "file": path, + "pdf_version": info.pdf_version, + "file_size": info.file_size, + "pages": info.pages.len(), + "pdf_pages": pdf_pages, "acroform_fields": acroform_fields, + "first_page_pt": info.pages.first().map(|p| [p.width, p.height]), + "title": info.title, "author": info.author, "producer": info.producer, "creator": info.creator, + "encrypted": info.encrypted, "tagged": info.tagged, "javascript": info.has_javascript, + "pdfa": declared.pdfa.as_ref().map(|(part, conformance)| format!("PDF/A-{part}{}", conformance.to_lowercase())), + "pdfua": declared.pdfua, + "output_intents": declared.output_intents, + "xfa": info.xfa.map(|x| match x { pdfcraft_render::Xfa::Static => "static", pdfcraft_render::Xfa::Dynamic => "dynamic" }), + "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(), + "custom_properties": info.custom.len(), + "page_labels": info.pages.iter().take(8).map(|p| p.label.clone()).collect::>(), + "warnings": info.warnings, + }); + stdout_line(format_args!("{}", serde_json::to_string_pretty(&json).unwrap_or_default()))?; + Ok(()) +} + +pub(crate) fn text(args: &[String]) -> Result<(), CliError> { + validate_options("text", args, TEXT_OPTIONS)?; + let path = *positional(args, TEXT_OPTIONS).first().ok_or("text: missing file")?; + let mut selected_page = None; + // Validate every supplied value before reading, retaining the first valid selection. + let mut options = args.iter(); + while let Some(option) = options.next() { + if option == "--page" { + let page = options + .next() + .and_then(|value| value.parse::().ok()) + .filter(|page| *page > 0) + .ok_or("bad --page: expected a positive page number")?; + selected_page.get_or_insert(page - 1); + } else if option.starts_with("--") { + // Match positional(): an option's operand is not itself another option. + options.next(); + } + } + let password = flag(args, "--password"); + let mut bytes = read(path)?; + // The renderer takes only the user password of R2–R4 files; `Session::open` takes either. + // Worked out from the file as read: `bytes` may become the session's display copy below. + let render_password = pdfcraft_engine::render_password_for(bytes.clone(), password); + // Printing the text is copying it, so ask what `text_extract` asks (#1645): without the + // permissions password, a document that doesn't allow copying gives no text. + let mut session = pdfcraft_engine::Session::new(); + if let Some(doc) = session.open(path, None, bytes.clone(), password).ok().and_then(|id| session.get(id)) { + // The engine's hint is about editing in the app; on the command line the way in is + // `--password`. + doc.check_copying() + .map_err(|_| "text: the document's permissions don't allow copying its text; pass the owner password with --password to extract it")?; + if doc.xfa.is_some() { + bytes = doc.display.clone(); + } + } + let mut r = PageRenderer::new(bytes.clone(), RenderConfig { password: render_password.as_deref().map(Arc::from), ..Default::default() }); + // A document the renderer could not open (wrong or missing password, unparseable file) has + // no pages, and "extract every page" of nothing would print nothing and exit 0 (#132). Ask + // `inspect` why instead, so a protected file fails the same way `info` does. + if r.page_count() == 0 { + let info = inspect(bytes.clone(), render_password.as_deref()).map_err(|e| format!("text: {e}"))?; + if !info.pages.is_empty() { + return Err("text: the document could not be parsed".into()); + } + } + let pages: Vec = match selected_page { + Some(page) => vec![page], + None => (0..r.page_count()).collect(), + }; + let mut failed: Vec = Vec::new(); + for (n, p) in pages.iter().enumerate() { + let out = r.render(RenderRequest { page: *p, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0, preview: false }); + if let Some(e) = out.error { + let _ = writeln!(std::io::stderr().lock(), "page {}: {e}", p + 1); + failed.push(p + 1); + continue; + } + if n > 0 { + stdout_line(format_args!("\u{c}"))?; + } + stdout_line(format_args!("{}", out.text.map(|t| t.plain_text()).unwrap_or_default()))?; + } + if failed.is_empty() { + Ok(()) + } else { + Err(format!("text: page(s) {} could not be read", failed.iter().map(usize::to_string).collect::>().join(", ")).into()) + } +} + +/// 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> { + 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. +const EDIT_OPTIONS: &str = "--out, --password, --full, --rotate, --delete, --move, --insert-blank, --title, --author"; + +/// Apply page and metadata edits through the engine and save (incrementally unless `--full`). +/// +/// Every argument is parsed before the file is opened: an option this command does not know +/// (a typo like `--rotat`) is an error, not something to skip — skipping it would write an +/// unchanged copy and exit 0, which looks like a successful edit (#133). +pub(crate) fn edit(args: &[String]) -> Result<(), CliError> { + use pdfcraft_engine::{Edit, Session}; + let mut path: Option<&str> = None; + let mut out: Option<&str> = None; + let mut password: Option<&str> = None; + let mut full = false; + let mut edits = Vec::new(); + let mut i = 0; + while let Some(arg) = args.get(i).map(String::as_str) { + // Options that take no value, and the input file. + match arg { + "--full" => { + full = true; + i += 1; + continue; + } + a if a.starts_with("--") => {} + a => { + if path.is_some() { + return Err(format!("edit: unexpected argument {a:?} (one input file, then options)").into()); + } + path = Some(a); + i += 1; + continue; + } + } + // `edit` parses its own options (they run in order), so `--out` is checked here in the + // same way: it wrote a PDF to a file called `--full`. + let value = args.get(i + 1).map(String::as_str); + if !can_be_path_value(arg, value) { + return Err(needs_value("edit", arg, value).into()); + } + let value = value.ok_or_else(|| format!("edit: {arg} needs a value"))?; + match arg { + "--out" => out = Some(value), + "--password" => password = Some(value), + "--rotate" => { + let (pages, deg) = value.split_once(':').ok_or("--rotate PAGES:DEGREES")?; + let degrees: i64 = deg.parse().map_err(|_| format!("bad --rotate degrees: expected a whole number of degrees, got {deg:?}"))?; + // The same rule `rotate_pages` enforces, checked here so a bad angle is named + // with its option before any file is opened. + if degrees % 90 != 0 { + return Err(format!("bad --rotate degrees: expected a multiple of 90 (such as 90, -90 or 180), got {degrees}").into()); + } + edits.push(Edit::RotatePages { pages: page_list(pages)?, degrees }); + } + "--delete" => edits.push(Edit::DeletePages { pages: page_list(value)? }), + "--move" => { + let (pages, to) = value.split_once(':').ok_or("--move PAGES:TO")?; + let target: usize = to + .parse() + .ok() + .filter(|target| *target > 0) + .ok_or_else(|| format!("bad --move target: expected a page number of at least 1, got {to:?}"))?; + edits.push(Edit::MovePages { pages: page_list(pages)?, to: target - 1 }); + } + "--insert-blank" => { + let position: usize = value + .parse() + .ok() + .filter(|position| *position > 0) + .ok_or_else(|| format!("bad --insert-blank position: expected a page position of at least 1, got {value:?}"))?; + edits.push(Edit::InsertBlankPage { at: position - 1, width: 612.0, height: 792.0 }); + } + "--title" => edits.push(Edit::SetInfo { key: "Title".into(), value: value.into() }), + "--author" => edits.push(Edit::SetInfo { key: "Author".into(), value: value.into() }), + other => return Err(format!("edit: unknown option {other:?} (expected one of: {EDIT_OPTIONS})").into()), + } + i += 2; + } + let path = path.ok_or("edit: missing file")?; + let out = out.ok_or("edit: missing --out")?; + let mut session = Session::new(); + let id = session.open(path, Some(path.to_string()), read(path)?, password).map_err(|e| e.to_string())?; + for e in edits { + // A destination past the end must fail like `page_move` / `page_insert_blank` do, not + // silently become an append (#1330). Checked per edit: earlier edits change the count. + let n = session.get(id).map_or(0, |d| d.info.pages.len()); + let destination = match &e { + Edit::MovePages { to, .. } => Some(("--move target", *to)), + Edit::InsertBlankPage { at, .. } => Some(("--insert-blank", *at)), + _ => None, + }; + if let Some((option, at)) = destination.filter(|(_, at)| *at > n) { + return Err(format!("{option} {} is past the end: it must be between 1 and {} (the document has {n} pages)", at + 1, n + 1).into()); + } + // Pages count from the document as the earlier options left it, so a page past the end is + // reported with its own option and the current page count (#1550). + let listed = match &e { + Edit::RotatePages { pages, .. } => Some(("--rotate", pages)), + Edit::DeletePages { pages } => Some(("--delete", pages)), + Edit::MovePages { pages, .. } => Some(("--move", pages)), + _ => None, + }; + if let Some((option, bad)) = listed.and_then(|(option, pages)| pages.iter().find(|p| **p >= n).map(|p| (option, *p))) { + return Err(format!("{option}: page {} does not exist; after the earlier options the document has {n} pages", bad + 1).into()); + } + 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())?; + // 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 { + Path::new(path).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| path.to_string()) +} + +pub(crate) fn combine(args: &[String]) -> Result<(), CliError> { + validate_options("combine", args, COMBINE_OPTIONS)?; + let out = flag(args, "--out").ok_or("combine: missing --out")?; + let inputs = positional(args, COMBINE_OPTIONS); + if inputs.len() < 2 { + return Err("combine: give at least two input files".into()); + } + 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())?; + write_pdf(Path::new(out), &bytes) +} + +pub(crate) fn extract(args: &[String]) -> Result<(), CliError> { + validate_options("extract", args, EXTRACT_OPTIONS)?; + let path = *positional(args, EXTRACT_OPTIONS).first().ok_or("extract: missing file")?; + let out = flag(args, "--out").ok_or("extract: missing --out")?; + let pages = page_list(flag(args, "--pages").ok_or("extract: missing --pages")?)?; + 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())?; + write_pdf(Path::new(out), &bytes) +} + +pub(crate) fn split(args: &[String]) -> Result<(), CliError> { + validate_options("split", args, SPLIT_OPTIONS)?; + use pdfcraft_engine::SplitBy; + let path = *positional(args, SPLIT_OPTIONS).first().ok_or("split: missing file")?; + let by = match (flag(args, "--every"), flag(args, "--before")) { + (Some(n), None) => { + // A count the splitter cannot hold is refused here, before the file is opened, + // rather than silently clamped to 1 further down. + let count = n + .parse::() + .ok() + .filter(|count| *count > 0) + .ok_or_else(|| format!("bad --every: expected a positive whole number of pages, got {n:?}"))?; + SplitBy::PageCount(count) + } + (None, Some(list)) => SplitBy::Before(page_list(list)?), + _ => return Err("split: give either --every N or --before PAGES".into()), + }; + let dir = PathBuf::from(flag(args, "--out-dir").unwrap_or(".")); + 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 stem = file_stem(path); + let parts = session.split(id, &by).map_err(|e| e.to_string())?; + // An output folder that does not exist yet is created (#249); one that can't be is reported + // as the folder's problem, not as the first part's. + 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") }); + write_pdf(&name, &bytes)?; + stdout_line(format_args!("{}", name.display()))?; + } + Ok(()) +} + +/// A `--dpi` the renderer can be asked for: a positive, finite number. `0`, a negative value +/// or `NaN` parse as an `f32` but reach the renderer as a scale it refuses with its own +/// internal message (and in `check`, passed on as text, they would turn every file into a +/// child that printed nothing), so the CLI names the option and the value instead. +pub(crate) fn parse_dpi(value: &str) -> Result { + value + .parse::() + .ok() + .filter(|dpi| dpi.is_finite() && *dpi > 0.0) + .ok_or_else(|| format!("bad --dpi: expected a positive number of dots per inch, got {value:?}")) +} + +pub(crate) fn render(args: &[String]) -> Result<(), CliError> { + validate_options("render", args, RENDER_OPTIONS)?; + let path = *positional(args, RENDER_OPTIONS).first().ok_or("render: missing file")?; + let page: usize = + flag(args, "--page").unwrap_or("1").parse().ok().filter(|page| *page > 0).ok_or("bad --page: expected a positive page number")?; + let page_index = page - 1; + let dpi = parse_dpi(flag(args, "--dpi").unwrap_or("96"))?; + let out = flag(args, "--out").ok_or("render: missing --out (.png, .jpg, .tif or .pam)")?; + // The file is what its name says (#248): a `.png` used to get a netpbm PAM stream. + let format = match Path::new(out).extension().and_then(std::ffi::OsStr::to_str).map(str::to_ascii_lowercase).as_deref() { + Some("png") => Some(ImageFormat::Png), + Some("jpg" | "jpeg") => Some(ImageFormat::Jpeg { quality: 90 }), + Some("tif" | "tiff") => Some(ImageFormat::Tiff), + 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 password = flag(args, "--password"); + // The renderer takes only the user password of R2–R4 files; `Session::open` and the display + // copy take either. + let render_password = pdfcraft_engine::render_password_for(bytes.clone(), password); + let display = if inspect(bytes.clone(), render_password.as_deref()).is_ok_and(|info| info.xfa == Some(pdfcraft_render::Xfa::Dynamic)) { + let mut session = pdfcraft_engine::Session::new(); + let id = session.open(path, None, bytes, password).map_err(|e| e.to_string())?; + session.get(id).ok_or("render: the document could not be opened")?.display.clone() + } else { + pdfcraft_engine::display_bytes_for(bytes, password) + }; + let mut r = + PageRenderer::new(display, RenderConfig { password: render_password.as_deref().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 }); + if let Some(e) = p.error { + return Err(e.into()); + } + let bytes = match format { + // With `reject_oversize` the page is drawn at `--dpi` or not at all, so the file can say so. + Some(f) => pdfcraft_engine::export::encode_image(p.width, p.height, &p.rgba, f, f64::from(dpi))?, + None => { + // PAM (netpbm RGB_ALPHA): the raw premultiplied pixels, for tools that read it. + let mut pam = format!("P7\nWIDTH {}\nHEIGHT {}\nDEPTH 4\nMAXVAL 255\nTUPLTYPE RGB_ALPHA\nENDHDR\n", p.width, p.height).into_bytes(); + pam.extend_from_slice(&p.rgba); + pam + } + }; + std::fs::write(out, bytes).map_err(|e| format!("{out}: {e}"))?; + let _ = writeln!(std::io::stderr().lock(), "rendered page {page} at {dpi} dpi: {}×{} px in {} ms", p.width, p.height, p.millis); + for warning in &p.warnings { + match warning { + pdfcraft_render::RenderWarning::ContentTruncated => { + let _ = writeln!(std::io::stderr().lock(), "warning: page {page}: content past the page's safety budget was skipped"); + } + pdfcraft_render::RenderWarning::ColourFallback => { + let _ = + writeln!(std::io::stderr().lock(), "warning: page {page}: a colour that could not be computed was painted in a fallback colour"); + } + } + } + Ok(()) +} diff --git a/apps/pdfcraft-cli/src/main.rs b/apps/pdfcraft-cli/src/main.rs index 9ac3b21589..0c7fa9ba22 100644 --- a/apps/pdfcraft-cli/src/main.rs +++ b/apps/pdfcraft-cli/src/main.rs @@ -53,6 +53,20 @@ macro_rules! mcp_usage { }; } +mod check; +mod commands; +mod options; +mod run; +mod ui; + +use check::{audit, check, check_one}; +use commands::{combine, edit, extract, info, render, split, text}; +use options::{OptionSpec, flag, validate_options}; +#[cfg(feature = "mcp")] +use run::mcp; +use run::{automation, print_output, run, tools}; +use ui::ui; + /// The full usage, printed by `--help` (also after a command, e.g. `render --help`). const USAGE: &str = concat!( "\ @@ -181,1055 +195,3 @@ fn version() -> Result<(), CliError> { stdout_line(format_args!("Source: {}", pdfcraft_engine::links::GITHUB))?; Ok(()) } - -/// Audit a document for faux redactions. Prints the findings as JSON; fails when any are -/// found so scripts and CI can gate on it, and when the audit itself cannot run. -fn audit(args: &[String]) -> Result<(), CliError> { - validate_options("audit", args, AUDIT_OPTIONS)?; - let path = *positional(args, AUDIT_OPTIONS).first().ok_or("audit: missing file")?; - let bytes = read(path)?; - let password = flag(args, "--password"); - let doc = pdfcraft_cos::Document::open_with_password(bytes.clone(), password).map_err(|e| format!("{path}: {e}"))?; - let findings = pdfcraft_audit::audit_redactions(&doc).map_err(|e| format!("{path}: {e}"))?; - // Rectangles in points from the top-left of the displayed page, as `doc_audit_redactions` - // reports them; the page geometry is read only when there is something to place. - let info = if findings.is_empty() { - None - } else { - let render_password = pdfcraft_engine::render_password_for(bytes.clone(), password); - Some(inspect(bytes, render_password.as_deref()).map_err(|e| format!("{path}: {e}"))?) - }; - let items: Vec = findings - .into_iter() - .map(|f| { - let rect = f.rect.map(|v| v as f32); - serde_json::json!({ - "page": f.page + 1, - "kind": f.kind.id(), - "rect": info.as_ref().map_or(rect, |i| pdfcraft_automation::view_rect(i, f.page, rect)), - "covered_text": f.covered_text, - }) - }) - .collect(); - println!("{}", serde_json::json!({ "file": path, "findings": items, "count": items.len() })); - if items.is_empty() { Ok(()) } else { Err(format!("audit: {} faux redaction(s) found", items.len()).into()) } -} - -fn flag<'a>(args: &'a [String], name: &str) -> Option<&'a str> { - args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).map(String::as_str) -} - -#[derive(Clone, Copy)] -struct OptionSpec { - name: &'static str, - takes_value: bool, -} - -/// The options whose value is a file or folder. Only these refuse a value that is another -/// option: a file named `--password` is never what was meant, while a password can be anything -/// (`--password --page` is a password of `--page`, which tests/text.rs pins), and so can a title. -/// The way to name a file that does start with `-` is `./--name`, as `--root` already offered. -const PATH_VALUE_OPTIONS: [&str; 6] = ["--out", "--out-dir", "--json", "--root", "--control", "--script"]; - -/// Whether a token can be the value of a path option. Another option cannot: `--out --password` -/// used to write the PDF to a file called `--password` and exit 0 (photocraft#6944). -fn can_be_path_value(name: &str, value: Option<&str>) -> bool { - !PATH_VALUE_OPTIONS.contains(&name) || value.is_some_and(|v| !v.is_empty() && !v.starts_with("--")) -} - -/// The complaint about a path option whose value is missing or is another option. -fn needs_value(command: &str, name: &str, value: Option<&str>) -> String { - match value { - Some(v) if !v.is_empty() => { - format!("{command}: {name} needs a value and `{v}` is another option; write `./{v}` for a file of that name") - } - _ => format!("{command}: {name} needs a value"), - } -} - -/// Refuse unknown options and malformed confinement flags before any file is read. -fn validate_options(command: &str, args: &[String], specs: &[OptionSpec]) -> Result<(), CliError> { - if specs.iter().any(|spec| spec.name == "--root") { - // `flag` searches every token, including another option's value. Match that lookup so - // a malformed confinement flag cannot be hidden there and fall back to no root. - let mut root_seen = false; - for (i, _) in args.iter().enumerate().filter(|(_, arg)| arg.as_str() == "--root") { - if root_seen { - return Err(format!("{command}: --root may only be supplied once").into()); - } - root_seen = true; - if args.get(i + 1).is_none_or(|value| value.is_empty() || value.starts_with("--")) { - return Err(format!("{command}: --root needs a directory value").into()); - } - } - } - let mut i = 0; - while let Some(arg) = args.get(i) { - if let Some(name) = arg.strip_prefix("--") { - let Some(spec) = specs.iter().find(|spec| spec.name == arg) else { - return Err(format!("{command}: unknown option --{name}").into()); - }; - // A file's name is not another option, which also closes the gap the walk left for - // those: an unknown option in that value position was skipped along with it. Other - // values, missing or malformed, are left to the command, which reports them in its - // own terms (`text` and `extract` explain what a page value must be). - i += if spec.takes_value { - let value = args.get(i + 1).map(String::as_str); - if !can_be_path_value(spec.name, value) { - return Err(needs_value(command, spec.name, value).into()); - } - 2 - } else { - 1 - }; - } else { - i += 1; - } - } - Ok(()) -} - -const INFO_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--password", takes_value: true }]; -const AUDIT_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--password", takes_value: true }]; -const TEXT_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--page", takes_value: true }, OptionSpec { name: "--password", takes_value: true }]; -const COMBINE_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--out", takes_value: true }]; -const EXTRACT_OPTIONS: &[OptionSpec] = &[ - OptionSpec { name: "--out", takes_value: true }, - OptionSpec { name: "--pages", takes_value: true }, - OptionSpec { name: "--password", takes_value: true }, -]; -const SPLIT_OPTIONS: &[OptionSpec] = &[ - OptionSpec { name: "--every", takes_value: true }, - OptionSpec { name: "--before", takes_value: true }, - OptionSpec { name: "--out-dir", takes_value: true }, - OptionSpec { name: "--password", takes_value: true }, -]; -const RENDER_OPTIONS: &[OptionSpec] = &[ - OptionSpec { name: "--page", takes_value: true }, - OptionSpec { name: "--dpi", takes_value: true }, - OptionSpec { name: "--out", takes_value: true }, - OptionSpec { name: "--password", takes_value: true }, -]; -const CHECK_ONE_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--dpi", takes_value: true }, OptionSpec { name: "--edit", takes_value: false }]; -const CHECK_OPTIONS: &[OptionSpec] = &[ - OptionSpec { name: "--timeout", takes_value: true }, - OptionSpec { name: "--dpi", takes_value: true }, - OptionSpec { name: "--json", takes_value: true }, -]; -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 }]; - -#[cfg(feature = "mcp")] -const MCP_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--root", takes_value: true }, OptionSpec { name: "--compact", takes_value: false }]; - -/// The arguments that are not options or their values. Which tokens are values is the spec's -/// answer: skipping the one after every option made `check-one --edit file.pdf` report "missing -/// file", the file having been read as `--edit`'s value. -fn positional<'a>(args: &'a [String], specs: &[OptionSpec]) -> Vec<&'a str> { - let mut out = Vec::new(); - let mut i = 0; - while let Some(a) = args.get(i) { - match specs.iter().find(|spec| spec.name == a.as_str()) { - Some(spec) if spec.takes_value => i += 2, - // An option that takes no value, and an unknown one, which `validate_options` has - // already refused by the time this runs. - Some(_) => i += 1, - None if a.starts_with("--") => i += 1, - None => { - out.push(a.as_str()); - i += 1; - } - } - } - out -} - -fn read(path: &str) -> Result>, String> { - std::fs::read(path).map(Arc::new).map_err(|e| format!("{path}: {e}")) -} - -fn info(args: &[String]) -> Result<(), CliError> { - validate_options("info", args, INFO_OPTIONS)?; - let path = *positional(args, INFO_OPTIONS).first().ok_or("info: missing file")?; - let bytes = read(path)?; - let password = flag(args, "--password"); - // The renderer takes only the user password of R2–R4 files; `Session::open` takes either. - let render_password = pdfcraft_engine::render_password_for(bytes.clone(), password); - let mut info = inspect(bytes.clone(), render_password.as_deref()).map_err(|e| e.to_string())?; - let pdf_pages = info.pages.len(); - let acroform_fields = info.fields.len(); - // Dynamic XFA forms: say what laying the template out gives (what the app shows). - let xfa_layout = (info.xfa == Some(pdfcraft_render::Xfa::Dynamic)) - .then(|| { - let mut s = pdfcraft_engine::Session::new(); - let id = s.open("info.pdf", None, bytes.clone(), password).ok()?; - let d = s.get(id)?; - d.xfa.as_ref().map(|x| { - info = d.info.clone(); - // The in-memory layout is an appended revision, not the file being inspected. - info.file_size = bytes.len(); - serde_json::json!({ "pages": x.pages, "fields": x.fields, "warnings": x.warnings }) - }) - }) - .flatten(); - // What the document declares as a standard, read the way the Standards panel reads it, so - // `info` and the app agree. A file that declares nothing leaves these null and empty. - let declared = - pdfcraft_cos::Document::open_with_password(bytes.clone(), password).map(|doc| pdfcraft_preflight::declared(&doc)).unwrap_or_default(); - let json = serde_json::json!({ - "file": path, - "pdf_version": info.pdf_version, - "file_size": info.file_size, - "pages": info.pages.len(), - "pdf_pages": pdf_pages, "acroform_fields": acroform_fields, - "first_page_pt": info.pages.first().map(|p| [p.width, p.height]), - "title": info.title, "author": info.author, "producer": info.producer, "creator": info.creator, - "encrypted": info.encrypted, "tagged": info.tagged, "javascript": info.has_javascript, - "pdfa": declared.pdfa.as_ref().map(|(part, conformance)| format!("PDF/A-{part}{}", conformance.to_lowercase())), - "pdfua": declared.pdfua, - "output_intents": declared.output_intents, - "xfa": info.xfa.map(|x| match x { pdfcraft_render::Xfa::Static => "static", pdfcraft_render::Xfa::Dynamic => "dynamic" }), - "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(), - "custom_properties": info.custom.len(), - "page_labels": info.pages.iter().take(8).map(|p| p.label.clone()).collect::>(), - "warnings": info.warnings, - }); - stdout_line(format_args!("{}", serde_json::to_string_pretty(&json).unwrap_or_default()))?; - Ok(()) -} - -fn text(args: &[String]) -> Result<(), CliError> { - validate_options("text", args, TEXT_OPTIONS)?; - let path = *positional(args, TEXT_OPTIONS).first().ok_or("text: missing file")?; - let mut selected_page = None; - // Validate every supplied value before reading, retaining the first valid selection. - let mut options = args.iter(); - while let Some(option) = options.next() { - if option == "--page" { - let page = options - .next() - .and_then(|value| value.parse::().ok()) - .filter(|page| *page > 0) - .ok_or("bad --page: expected a positive page number")?; - selected_page.get_or_insert(page - 1); - } else if option.starts_with("--") { - // Match positional(): an option's operand is not itself another option. - options.next(); - } - } - let password = flag(args, "--password"); - let mut bytes = read(path)?; - // The renderer takes only the user password of R2–R4 files; `Session::open` takes either. - // Worked out from the file as read: `bytes` may become the session's display copy below. - let render_password = pdfcraft_engine::render_password_for(bytes.clone(), password); - // Printing the text is copying it, so ask what `text_extract` asks (#1645): without the - // permissions password, a document that doesn't allow copying gives no text. - let mut session = pdfcraft_engine::Session::new(); - if let Some(doc) = session.open(path, None, bytes.clone(), password).ok().and_then(|id| session.get(id)) { - // The engine's hint is about editing in the app; on the command line the way in is - // `--password`. - doc.check_copying() - .map_err(|_| "text: the document's permissions don't allow copying its text; pass the owner password with --password to extract it")?; - if doc.xfa.is_some() { - bytes = doc.display.clone(); - } - } - let mut r = PageRenderer::new(bytes.clone(), RenderConfig { password: render_password.as_deref().map(Arc::from), ..Default::default() }); - // A document the renderer could not open (wrong or missing password, unparseable file) has - // no pages, and "extract every page" of nothing would print nothing and exit 0 (#132). Ask - // `inspect` why instead, so a protected file fails the same way `info` does. - if r.page_count() == 0 { - let info = inspect(bytes.clone(), render_password.as_deref()).map_err(|e| format!("text: {e}"))?; - if !info.pages.is_empty() { - return Err("text: the document could not be parsed".into()); - } - } - let pages: Vec = match selected_page { - Some(page) => vec![page], - None => (0..r.page_count()).collect(), - }; - let mut failed: Vec = Vec::new(); - for (n, p) in pages.iter().enumerate() { - let out = r.render(RenderRequest { page: *p, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0, preview: false }); - if let Some(e) = out.error { - let _ = writeln!(std::io::stderr().lock(), "page {}: {e}", p + 1); - failed.push(p + 1); - continue; - } - if n > 0 { - stdout_line(format_args!("\u{c}"))?; - } - stdout_line(format_args!("{}", out.text.map(|t| t.plain_text()).unwrap_or_default()))?; - } - if failed.is_empty() { - Ok(()) - } else { - Err(format!("text: page(s) {} could not be read", failed.iter().map(usize::to_string).collect::>().join(", ")).into()) - } -} - -/// 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> { - 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. -const EDIT_OPTIONS: &str = "--out, --password, --full, --rotate, --delete, --move, --insert-blank, --title, --author"; - -/// Apply page and metadata edits through the engine and save (incrementally unless `--full`). -/// -/// Every argument is parsed before the file is opened: an option this command does not know -/// (a typo like `--rotat`) is an error, not something to skip — skipping it would write an -/// unchanged copy and exit 0, which looks like a successful edit (#133). -fn edit(args: &[String]) -> Result<(), CliError> { - use pdfcraft_engine::{Edit, Session}; - let mut path: Option<&str> = None; - let mut out: Option<&str> = None; - let mut password: Option<&str> = None; - let mut full = false; - let mut edits = Vec::new(); - let mut i = 0; - while let Some(arg) = args.get(i).map(String::as_str) { - // Options that take no value, and the input file. - match arg { - "--full" => { - full = true; - i += 1; - continue; - } - a if a.starts_with("--") => {} - a => { - if path.is_some() { - return Err(format!("edit: unexpected argument {a:?} (one input file, then options)").into()); - } - path = Some(a); - i += 1; - continue; - } - } - // `edit` parses its own options (they run in order), so `--out` is checked here in the - // same way: it wrote a PDF to a file called `--full`. - let value = args.get(i + 1).map(String::as_str); - if !can_be_path_value(arg, value) { - return Err(needs_value("edit", arg, value).into()); - } - let value = value.ok_or_else(|| format!("edit: {arg} needs a value"))?; - match arg { - "--out" => out = Some(value), - "--password" => password = Some(value), - "--rotate" => { - let (pages, deg) = value.split_once(':').ok_or("--rotate PAGES:DEGREES")?; - let degrees: i64 = deg.parse().map_err(|_| format!("bad --rotate degrees: expected a whole number of degrees, got {deg:?}"))?; - // The same rule `rotate_pages` enforces, checked here so a bad angle is named - // with its option before any file is opened. - if degrees % 90 != 0 { - return Err(format!("bad --rotate degrees: expected a multiple of 90 (such as 90, -90 or 180), got {degrees}").into()); - } - edits.push(Edit::RotatePages { pages: page_list(pages)?, degrees }); - } - "--delete" => edits.push(Edit::DeletePages { pages: page_list(value)? }), - "--move" => { - let (pages, to) = value.split_once(':').ok_or("--move PAGES:TO")?; - let target: usize = to - .parse() - .ok() - .filter(|target| *target > 0) - .ok_or_else(|| format!("bad --move target: expected a page number of at least 1, got {to:?}"))?; - edits.push(Edit::MovePages { pages: page_list(pages)?, to: target - 1 }); - } - "--insert-blank" => { - let position: usize = value - .parse() - .ok() - .filter(|position| *position > 0) - .ok_or_else(|| format!("bad --insert-blank position: expected a page position of at least 1, got {value:?}"))?; - edits.push(Edit::InsertBlankPage { at: position - 1, width: 612.0, height: 792.0 }); - } - "--title" => edits.push(Edit::SetInfo { key: "Title".into(), value: value.into() }), - "--author" => edits.push(Edit::SetInfo { key: "Author".into(), value: value.into() }), - other => return Err(format!("edit: unknown option {other:?} (expected one of: {EDIT_OPTIONS})").into()), - } - i += 2; - } - let path = path.ok_or("edit: missing file")?; - let out = out.ok_or("edit: missing --out")?; - let mut session = Session::new(); - let id = session.open(path, Some(path.to_string()), read(path)?, password).map_err(|e| e.to_string())?; - for e in edits { - // A destination past the end must fail like `page_move` / `page_insert_blank` do, not - // silently become an append (#1330). Checked per edit: earlier edits change the count. - let n = session.get(id).map_or(0, |d| d.info.pages.len()); - let destination = match &e { - Edit::MovePages { to, .. } => Some(("--move target", *to)), - Edit::InsertBlankPage { at, .. } => Some(("--insert-blank", *at)), - _ => None, - }; - if let Some((option, at)) = destination.filter(|(_, at)| *at > n) { - return Err(format!("{option} {} is past the end: it must be between 1 and {} (the document has {n} pages)", at + 1, n + 1).into()); - } - // Pages count from the document as the earlier options left it, so a page past the end is - // reported with its own option and the current page count (#1550). - let listed = match &e { - Edit::RotatePages { pages, .. } => Some(("--rotate", pages)), - Edit::DeletePages { pages } => Some(("--delete", pages)), - Edit::MovePages { pages, .. } => Some(("--move", pages)), - _ => None, - }; - if let Some((option, bad)) = listed.and_then(|(option, pages)| pages.iter().find(|p| **p >= n).map(|p| (option, *p))) { - return Err(format!("{option}: page {} does not exist; after the earlier options the document has {n} pages", bad + 1).into()); - } - 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())?; - // 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 { - Path::new(path).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| path.to_string()) -} - -fn combine(args: &[String]) -> Result<(), CliError> { - validate_options("combine", args, COMBINE_OPTIONS)?; - let out = flag(args, "--out").ok_or("combine: missing --out")?; - let inputs = positional(args, COMBINE_OPTIONS); - if inputs.len() < 2 { - return Err("combine: give at least two input files".into()); - } - 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())?; - write_pdf(Path::new(out), &bytes) -} - -fn extract(args: &[String]) -> Result<(), CliError> { - validate_options("extract", args, EXTRACT_OPTIONS)?; - let path = *positional(args, EXTRACT_OPTIONS).first().ok_or("extract: missing file")?; - let out = flag(args, "--out").ok_or("extract: missing --out")?; - let pages = page_list(flag(args, "--pages").ok_or("extract: missing --pages")?)?; - 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())?; - write_pdf(Path::new(out), &bytes) -} - -fn split(args: &[String]) -> Result<(), CliError> { - validate_options("split", args, SPLIT_OPTIONS)?; - use pdfcraft_engine::SplitBy; - let path = *positional(args, SPLIT_OPTIONS).first().ok_or("split: missing file")?; - let by = match (flag(args, "--every"), flag(args, "--before")) { - (Some(n), None) => { - // A count the splitter cannot hold is refused here, before the file is opened, - // rather than silently clamped to 1 further down. - let count = n - .parse::() - .ok() - .filter(|count| *count > 0) - .ok_or_else(|| format!("bad --every: expected a positive whole number of pages, got {n:?}"))?; - SplitBy::PageCount(count) - } - (None, Some(list)) => SplitBy::Before(page_list(list)?), - _ => return Err("split: give either --every N or --before PAGES".into()), - }; - let dir = PathBuf::from(flag(args, "--out-dir").unwrap_or(".")); - 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 stem = file_stem(path); - let parts = session.split(id, &by).map_err(|e| e.to_string())?; - // An output folder that does not exist yet is created (#249); one that can't be is reported - // as the folder's problem, not as the first part's. - 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") }); - write_pdf(&name, &bytes)?; - stdout_line(format_args!("{}", name.display()))?; - } - Ok(()) -} - -/// A `--dpi` the renderer can be asked for: a positive, finite number. `0`, a negative value -/// or `NaN` parse as an `f32` but reach the renderer as a scale it refuses with its own -/// internal message (and in `check`, passed on as text, they would turn every file into a -/// child that printed nothing), so the CLI names the option and the value instead. -fn parse_dpi(value: &str) -> Result { - value - .parse::() - .ok() - .filter(|dpi| dpi.is_finite() && *dpi > 0.0) - .ok_or_else(|| format!("bad --dpi: expected a positive number of dots per inch, got {value:?}")) -} - -fn render(args: &[String]) -> Result<(), CliError> { - validate_options("render", args, RENDER_OPTIONS)?; - let path = *positional(args, RENDER_OPTIONS).first().ok_or("render: missing file")?; - let page: usize = - flag(args, "--page").unwrap_or("1").parse().ok().filter(|page| *page > 0).ok_or("bad --page: expected a positive page number")?; - let page_index = page - 1; - let dpi = parse_dpi(flag(args, "--dpi").unwrap_or("96"))?; - let out = flag(args, "--out").ok_or("render: missing --out (.png, .jpg, .tif or .pam)")?; - // The file is what its name says (#248): a `.png` used to get a netpbm PAM stream. - let format = match Path::new(out).extension().and_then(std::ffi::OsStr::to_str).map(str::to_ascii_lowercase).as_deref() { - Some("png") => Some(ImageFormat::Png), - Some("jpg" | "jpeg") => Some(ImageFormat::Jpeg { quality: 90 }), - Some("tif" | "tiff") => Some(ImageFormat::Tiff), - 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 password = flag(args, "--password"); - // The renderer takes only the user password of R2–R4 files; `Session::open` and the display - // copy take either. - let render_password = pdfcraft_engine::render_password_for(bytes.clone(), password); - let display = if inspect(bytes.clone(), render_password.as_deref()).is_ok_and(|info| info.xfa == Some(pdfcraft_render::Xfa::Dynamic)) { - let mut session = pdfcraft_engine::Session::new(); - let id = session.open(path, None, bytes, password).map_err(|e| e.to_string())?; - session.get(id).ok_or("render: the document could not be opened")?.display.clone() - } else { - pdfcraft_engine::display_bytes_for(bytes, password) - }; - let mut r = - PageRenderer::new(display, RenderConfig { password: render_password.as_deref().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 }); - if let Some(e) = p.error { - return Err(e.into()); - } - let bytes = match format { - // With `reject_oversize` the page is drawn at `--dpi` or not at all, so the file can say so. - Some(f) => pdfcraft_engine::export::encode_image(p.width, p.height, &p.rgba, f, f64::from(dpi))?, - None => { - // PAM (netpbm RGB_ALPHA): the raw premultiplied pixels, for tools that read it. - let mut pam = format!("P7\nWIDTH {}\nHEIGHT {}\nDEPTH 4\nMAXVAL 255\nTUPLTYPE RGB_ALPHA\nENDHDR\n", p.width, p.height).into_bytes(); - pam.extend_from_slice(&p.rgba); - pam - } - }; - std::fs::write(out, bytes).map_err(|e| format!("{out}: {e}"))?; - let _ = writeln!(std::io::stderr().lock(), "rendered page {page} at {dpi} dpi: {}×{} px in {} ms", p.width, p.height, p.millis); - for warning in &p.warnings { - match warning { - pdfcraft_render::RenderWarning::ContentTruncated => { - let _ = writeln!(std::io::stderr().lock(), "warning: page {page}: content past the page's safety budget was skipped"); - } - pdfcraft_render::RenderWarning::ColourFallback => { - let _ = - writeln!(std::io::stderr().lock(), "warning: page {page}: a colour that could not be computed was painted in a fallback colour"); - } - } - } - Ok(()) -} - -/// Child-process body for `check`: prints one JSON line. -fn check_one(args: &[String]) -> Result<(), CliError> { - validate_options("check-one", args, CHECK_ONE_OPTIONS)?; - let path = *positional(args, CHECK_ONE_OPTIONS).first().ok_or("check-one: missing file")?; - let dpi = parse_dpi(flag(args, "--dpi").unwrap_or("36"))?; - let start = Instant::now(); - let bytes = read(path)?; - let (status, pages, failed, detail, warnings) = match inspect(bytes.clone(), None) { - Err(e) => (format!("open-error:{}", short(&e.to_string())), 0, 0, e.to_string(), 0), - Ok(info) => { - let mut r = PageRenderer::new(bytes, RenderConfig::default()); - let mut failed = Vec::new(); - for i in 0..info.pages.len().min(500) { - let p = r.render(RenderRequest { page: i, kind: RequestKind::Pixels, tile: None, scale: dpi / 72.0, tag: 0, preview: false }); - if let Some(e) = r.render(RenderRequest { page: i, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0, preview: false }).error { - failed.push(format!("p{} text: {e}", i + 1)); - } - if let Some(e) = p.error { - failed.push(format!("p{}: {e}", i + 1)); - } - } - let status = if failed.is_empty() { "ok".to_string() } else { "page-errors".to_string() }; - (status, info.pages.len(), failed.len(), failed.join(" | "), info.warnings.len()) - } - }; - // `--edit` (fuzzing): also run our own object layer end to end, with no panic safety net, - // so a panic in cos shows up as a crash of this process. - if args.iter().any(|a| a == "--edit") { - edit_round_trip(&std::fs::read(path).map_err(|e| e.to_string())?); - } - let line = serde_json::json!({ "file": path, "status": status, "pages": pages, "failed_pages": failed, "warnings": warnings, - "ms": start.elapsed().as_millis() as u64, "detail": detail.chars().take(400).collect::() }); - stdout_line(format_args!("{line}"))?; - Ok(()) -} - -/// Open with cos, touch every object, edit the catalog, save incrementally and in full (both -/// output styles), and reopen each result. Errors are fine; panics, hangs and aborts are bugs. -fn edit_round_trip(bytes: &[u8]) { - use pdfcraft_cos::{Document, Object, SaveOptions, write_full, write_incremental}; - let Ok(mut doc) = Document::open(Arc::new(bytes.to_vec())) else { return }; - for num in doc.object_numbers().into_iter().take(20_000) { - if let Ok(o) = doc.try_get(num) - && let Object::Stream(s) = &*o - { - let _ = s.decoded(); - } - } - if let Some(root) = doc.root() { - let _ = doc.update_dict(root, |d| d.set(b"PdfCraftFuzz".to_vec(), Object::Bool(true))); - } - let classic = SaveOptions { object_streams: false, ..SaveOptions::default() }; - for out in [write_incremental(&doc, &SaveOptions::default()), write_full(&doc, &SaveOptions::default()), write_full(&doc, &classic)] - .into_iter() - .flatten() - { - if let Ok(again) = Document::open(Arc::new(out)) { - let _ = again.root().map(|r| again.get(r)); - } - } -} - -fn short(s: &str) -> String { - s.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).take(4).collect::>().join("-").to_lowercase() -} - -fn collect(paths: &[&str]) -> Vec { - let mut out = Vec::new(); - let mut stack: Vec = paths.iter().map(PathBuf::from).collect(); - let mut visited = std::collections::HashSet::new(); - while let Some(p) = stack.pop() { - if p.is_dir() { - // Directory aliases and symlink cycles must not scan the same tree repeatedly. - let identity = match p.canonicalize() { - Ok(identity) => identity, - Err(error) => { - eprintln!("{}: could not resolve directory: {error}", p.display()); - continue; - } - }; - if !visited.insert(identity) { - continue; - } - if let Ok(rd) = std::fs::read_dir(&p) { - stack.extend(rd.flatten().map(|e| e.path())); - } - } else if p.extension().and_then(std::ffi::OsStr::to_str).is_some_and(|e| e.eq_ignore_ascii_case("pdf")) { - out.push(p); - } - } - out.sort(); - out -} - -fn check(args: &[String]) -> Result<(), CliError> { - validate_options("check", args, CHECK_OPTIONS)?; - // Every option is checked before the inputs are scanned, so a bad value is one message, - // not one problem per file: a zero timeout used to make every file time out, and a bad - // dpi used to reach each child, which printed nothing and was reported as a crash. - let timeout_value = flag(args, "--timeout").unwrap_or("20"); - let timeout = Duration::from_secs( - timeout_value - .parse::() - .ok() - .filter(|secs| *secs > 0) - .ok_or_else(|| format!("bad --timeout: expected a positive whole number of seconds, got {timeout_value:?}"))?, - ); - let dpi = flag(args, "--dpi").unwrap_or("36").to_string(); - parse_dpi(&dpi)?; - let files = collect(&positional(args, CHECK_OPTIONS)); - if files.is_empty() { - return Err("check: no PDF files found".into()); - } - let exe = std::env::current_exe().map_err(|e| e.to_string())?; - let mut results = Vec::new(); - let mut counts: std::collections::BTreeMap = Default::default(); - for (n, f) in files.iter().enumerate() { - let r = run_child(&exe, f, &dpi, timeout); - let status = r["status"].as_str().unwrap_or("?").to_string(); - *counts.entry(status.split(':').next().unwrap_or("?").to_string()).or_default() += 1; - if status != "ok" { - eprintln!( - "[{}/{}] {status:<28} {} {}", - n + 1, - files.len(), - f.display(), - r["detail"].as_str().unwrap_or("").chars().take(160).collect::() - ); - } - results.push(r); - } - eprintln!("\nchecked {} files: {:?}", files.len(), counts); - if let Some(out) = flag(args, "--json") { - std::fs::write(out, serde_json::to_string_pretty(&results).unwrap_or_default()).map_err(|e| e.to_string())?; - } - Ok(()) -} - -fn run_child(exe: &Path, file: &Path, dpi: &str, timeout: Duration) -> serde_json::Value { - let file_s = file.to_string_lossy().to_string(); - let child = Command::new(exe).args(["check-one", &file_s, "--dpi", dpi]).stdout(Stdio::piped()).stderr(Stdio::null()).spawn(); - let mut child = match child { - Ok(c) => c, - Err(e) => return serde_json::json!({ "file": file_s, "status": "harness-error", "detail": e.to_string() }), - }; - let start = Instant::now(); - loop { - match child.try_wait() { - Ok(Some(status)) => { - let mut out = String::new(); - if let Some(mut so) = child.stdout.take() { - use std::io::Read; - let _ = so.read_to_string(&mut out); - } - return match serde_json::from_str::(out.trim()) { - Ok(v) => v, - Err(_) => serde_json::json!({ "file": file_s, "status": "crash", "detail": format!("child exited with {status}") }), - }; - } - Ok(None) if start.elapsed() > timeout => { - let _ = child.kill(); - let _ = child.wait(); - return serde_json::json!({ "file": file_s, "status": "timeout", "detail": format!("exceeded {}s", timeout.as_secs()) }); - } - Ok(None) => std::thread::sleep(Duration::from_millis(20)), - Err(e) => return serde_json::json!({ "file": file_s, "status": "harness-error", "detail": e.to_string() }), - } - } -} - -// ---- automation -------------------------------------------------------------------------------- - -fn automation(args: &[String]) -> Result { - let a = pdfcraft_automation::Automation::new(); - match flag(args, "--root") { - Some(root) => a.with_root(root).map_err(|e| format!("--root {root}: {e}")), - None => Ok(a), - } -} - -fn tools() -> Result<(), CliError> { - let list: Vec = pdfcraft_automation::tools() - .iter() - .map(|t| serde_json::json!({ "name": t.name, "description": t.description, "read_only": t.read_only, "command": t.command, "input_schema": t.input_schema })) - .collect(); - stdout_line(format_args!("{}", serde_json::to_string_pretty(&list).unwrap_or_default()))?; - Ok(()) -} - -/// Print a tool's result: JSON as JSON; images go to `--out` (or are summarised). The image is -/// written like a tool's own output, so `--root` confines it too. -fn print_output(auto: &pdfcraft_automation::Automation, content: Vec, out: Option<&str>) -> Result<(), CliError> { - for c in content { - match c { - pdfcraft_automation::Content::Json(v) => stdout_line(format_args!("{}", serde_json::to_string_pretty(&v).unwrap_or_default()))?, - pdfcraft_automation::Content::Png { data, width, height } => match out { - Some(path) => { - let written = auto.write_output(path, &data).map_err(|e| e.to_string())?; - stdout_line(format_args!("{}", serde_json::json!({ "image": written.to_string_lossy(), "width": width, "height": height })))?; - } - 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))?; - print_output(&auto, content, step["out"].as_str()).map_err(|e| match e { - CliError::Message(message) => CliError::Message(format!("step {} ({tool}): {message}", i + 1)), - stdout => stdout, - })?; - } - return Ok(()); - } - let tool = *positional(args, RUN_OPTIONS).first().ok_or("run: missing tool name (see `pdfcraft-cli tools`)")?; - let mut obj = serde_json::Map::new(); - for kv in positional(args, RUN_OPTIONS).iter().skip(1) { - let (k, v) = kv.split_once('=').ok_or(format!("run: expected key=value, got {kv:?}"))?; - let value = serde_json::from_str(v).unwrap_or_else(|_| serde_json::Value::String(v.to_string())); - obj.insert(k.to_string(), value); - } - let content = auto.call(tool, &serde_json::Value::Object(obj)).map_err(|e| e.to_string())?; - print_output(&auto, content, flag(args, "--out")) -} - -/// Take `--automation-read-root` / `--automation-write-root` (each `--flag DIR` or `--flag=DIR`, -/// once) out of `args`, and open the roots, which must be existing folders. Returns the arguments -/// that are left. A malformed root flag is an error, never a root silently left out. -#[cfg(feature = "mcp")] -fn automation_roots(command: &str, args: &[String]) -> Result<(Option>, Vec), String> { - let mut found = automation_args::AutomationArgs::new(None, None); - let mut left = Vec::new(); - let mut it = args.iter().cloned(); - while let Some(arg) = it.next() { - if !found.consume(&arg, &mut it).map_err(|e| format!("{command}: {e}"))? { - left.push(arg); - } - } - let roots = pdfcraft_automation::file_access::AutomationRoots::new(found.read_root.as_deref(), found.write_root.as_deref()) - .map_err(|e| format!("{command}: {e}"))?; - Ok((roots, left)) -} - -#[cfg(feature = "mcp")] -fn mcp(args: &[String]) -> Result<(), CliError> { - let (roots, args) = automation_roots("mcp", args)?; - let args = args.as_slice(); - validate_options("mcp", args, MCP_OPTIONS)?; - let compact = args.iter().any(|a| a == "--compact"); - let mut auto = automation(args)?; - if let Some(roots) = roots { - let show = |root: Option<&Path>| root.map_or_else(|| "none".to_string(), |r| r.display().to_string()); - let _ = writeln!( - std::io::stderr().lock(), - "pdfcraft-cli: files confined: read root {}, write root {}", - show(roots.read_root()), - show(roots.write_root()) - ); - auto = auto.with_automation_roots(Some(roots)); - } - let mut server = pdfcraft_automation::mcp::McpServer::new(auto).with_compact(compact); - let _ = writeln!( - std::io::stderr().lock(), - "pdfcraft-cli: MCP server on stdio (protocol {}{}); close stdin to stop", - pdfcraft_automation::mcp::PROTOCOL_VERSIONS[0], - if compact { ", compact tool list" } else { "" } - ); - server.serve(std::io::stdin().lock(), std::io::stdout().lock()).map_err(|e| CliError::Message(e.to_string())) -} - -// ---- UI control channel client ----------------------------------------------------------------- - -/// One request to a running app's control channel (`pdfcraft --control FILE`). -fn ui(args: &[String]) -> Result<(), CliError> { - validate_options("ui", args, UI_OPTIONS)?; - use std::io::{BufRead, BufReader, Write as _}; - let file = flag(args, "--control").ok_or("ui: missing --control FILE (start the app with `pdfcraft --control FILE`)")?; - let info = read_control_file(file)?; - let port = info["port"].as_u64().ok_or(format!("{file}: no port"))?; - // Refused rather than truncated, which would quietly reach some other port. - let port = u16::try_from(port).map_err(|_| format!("{file}: {port} is not a port number"))?; - let token = info["token"].as_str().ok_or(format!("{file}: no token"))?; - let pos = positional(args, UI_OPTIONS); - let method = *pos.first().ok_or("ui: missing method (state, inspect, click, drag, type, key, command, commands, set, open, screenshot)")?; - let method = if method.starts_with("ui.") { method.to_string() } else { format!("ui.{method}") }; - let mut params = serde_json::Map::new(); - for kv in pos.iter().skip(1) { - let (k, v) = kv.split_once('=').ok_or(format!("ui: expected key=value, got {kv:?}"))?; - params.insert(k.to_string(), serde_json::from_str(v).unwrap_or_else(|_| serde_json::Value::String(v.to_string()))); - } - let stream = - std::net::TcpStream::connect(("127.0.0.1", port)).map_err(|e| format!("can't reach the app on port {port}: {e} (is it still running?)"))?; - stream.set_read_timeout(Some(Duration::from_secs(40))).map_err(|e| e.to_string())?; - let mut write = stream.try_clone().map_err(|e| e.to_string())?; - let mut lines = BufReader::new(stream).lines(); - let mut rpc = |id: u64, method: &str, params: serde_json::Value| -> Result { - writeln!(write, "{}", serde_json::json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params })).map_err(|e| e.to_string())?; - let line = lines.next().ok_or("the app closed the connection")?.map_err(|e| e.to_string())?; - let reply: serde_json::Value = serde_json::from_str(&line).map_err(|e| e.to_string())?; - match reply.get("error") { - Some(e) => Err(e["message"].as_str().unwrap_or("error").to_string()), - None => Ok(reply["result"].clone()), - } - }; - rpc(1, "auth", serde_json::json!({ "token": token }))?; - let mut result = rpc(2, &method, serde_json::Value::Object(params))?; - if let (Some(out), Some(data)) = (flag(args, "--out"), result.get("png_base64").and_then(|d| d.as_str())) { - use base64::Engine as _; - let png = base64::engine::general_purpose::STANDARD.decode(data).map_err(|e| e.to_string())?; - std::fs::write(out, png).map_err(|e| format!("{out}: {e}"))?; - result["png_base64"] = serde_json::json!(format!("written to {out}")); - } - stdout_line(format_args!("{}", serde_json::to_string_pretty(&result).unwrap_or_default()))?; - Ok(()) -} - -/// Most a control file may hold. The app writes under 100 bytes. -const CONTROL_FILE_MAX: u64 = 64 * 1024; - -const CONTROL_FILE_ADVICE: &str = - "Start the app with a control file in a folder only you can write, such as `pdfcraft --control ~/.pdfcraft-control.json`"; - -/// Read the file written by `pdfcraft --control FILE`. -/// -/// It names the port that receives the token and every command, typed text included, so it must -/// be the user's own. In a shared folder such as `/tmp`, another local user can create the file -/// first; the app then can't write it, and the CLI would talk to the other user's listener. -/// Refused: a symbolic link, anything but a regular file, and on Unix a file owned by someone -/// else or open to other users (the app writes it with mode 600). -fn read_control_file(file: &str) -> Result { - use std::io::Read as _; - let at_path = std::fs::symlink_metadata(file).map_err(|e| format!("{file}: {e}"))?; - if at_path.file_type().is_symlink() { - return Err(format!("{file}: is a symbolic link, which could point at another user's file. {CONTROL_FILE_ADVICE}")); - } - if !at_path.is_file() { - return Err(format!("{file}: not a regular file. {CONTROL_FILE_ADVICE}")); - } - let f = std::fs::File::open(file).map_err(|e| format!("{file}: {e}"))?; - #[cfg(unix)] - { - use std::os::unix::fs::MetadataExt; - // Checked on the open file, so nothing swapped in after `symlink_metadata` gets through. - let opened = f.metadata().map_err(|e| format!("{file}: {e}"))?; - if (opened.dev(), opened.ino()) != (at_path.dev(), at_path.ino()) { - return Err(format!("{file}: was replaced while being opened. {CONTROL_FILE_ADVICE}")); - } - if let Some(problem) = control_file_problem(opened.uid(), opened.mode(), current_uid()) { - return Err(format!("{file}: {problem}. {CONTROL_FILE_ADVICE}")); - } - } - let mut text = String::new(); - f.take(CONTROL_FILE_MAX.saturating_add(1)).read_to_string(&mut text).map_err(|e| format!("{file}: {e}"))?; - if u64::try_from(text.len()).unwrap_or(u64::MAX) > CONTROL_FILE_MAX { - return Err(format!("{file}: too large for a control file (over {} KiB)", CONTROL_FILE_MAX / 1024)); - } - serde_json::from_str(&text).map_err(|e| format!("{file}: {e}")) -} - -/// Why user `me` can't trust a control file with this owner and mode, if they can't. -#[cfg(any(test, unix))] -fn control_file_problem(owner: u32, mode: u32, me: u32) -> Option { - if owner != me { - return Some(format!("owned by another user (uid {owner}; you are uid {me}), who may be the one listening for your commands")); - } - let permissions = mode & 0o7777; - (permissions & 0o077 != 0).then(|| format!("other users have access to it (mode {permissions:o}; the app writes it with mode 600)")) -} - -/// The effective user id: the owner of the files this process creates, and so of a control file -/// the app wrote for this user. std has no `geteuid` and this crate forbids `unsafe`; rustix's is -/// safe and can't fail. -#[cfg(unix)] -fn current_uid() -> u32 { - rustix::process::geteuid().as_raw() -} - -#[cfg(test)] -mod tests { - use super::control_file_problem; - - #[test] - fn a_control_file_must_be_yours_and_private() { - // Regular file type bits (0o100000) don't matter, only the owner and the permission bits. - assert_eq!(control_file_problem(1000, 0o100600, 1000), None); - assert_eq!(control_file_problem(1000, 0o600, 1000), None); - assert_eq!(control_file_problem(1000, 0o400, 1000), None); - let other = control_file_problem(1001, 0o600, 1000).unwrap_or_default(); - assert!(other.contains("owned by another user") && other.contains("1001"), "{other}"); - // Root gets no exception: a file a user planted is still theirs. - assert!(control_file_problem(1000, 0o600, 0).is_some_and(|p| p.contains("owned by another user"))); - for mode in [0o644, 0o640, 0o604, 0o660, 0o606, 0o620, 0o602, 0o610, 0o601, 0o100666] { - let open = control_file_problem(1000, mode, 1000).unwrap_or_default(); - assert!(open.contains("other users"), "{mode:o}: {open}"); - } - assert!(control_file_problem(1000, 0o100644, 1000).unwrap_or_default().contains("mode 644")); - } -} diff --git a/apps/pdfcraft-cli/src/options.rs b/apps/pdfcraft-cli/src/options.rs new file mode 100644 index 0000000000..1ddbfabcd5 --- /dev/null +++ b/apps/pdfcraft-cli/src/options.rs @@ -0,0 +1,148 @@ +use super::CliError; + +pub(crate) fn flag<'a>(args: &'a [String], name: &str) -> Option<&'a str> { + args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).map(String::as_str) +} + +#[derive(Clone, Copy)] +pub(crate) struct OptionSpec { + pub(crate) name: &'static str, + pub(crate) takes_value: bool, +} + +/// The options whose value is a file or folder. Only these refuse a value that is another +/// option: a file named `--password` is never what was meant, while a password can be anything +/// (`--password --page` is a password of `--page`, which tests/text.rs pins), and so can a title. +/// The way to name a file that does start with `-` is `./--name`, as `--root` already offered. +const PATH_VALUE_OPTIONS: [&str; 6] = ["--out", "--out-dir", "--json", "--root", "--control", "--script"]; + +/// Whether a token can be the value of a path option. Another option cannot: `--out --password` +/// used to write the PDF to a file called `--password` and exit 0 (photocraft#6944). +pub(crate) fn can_be_path_value(name: &str, value: Option<&str>) -> bool { + !PATH_VALUE_OPTIONS.contains(&name) || value.is_some_and(|v| !v.is_empty() && !v.starts_with("--")) +} + +/// The complaint about a path option whose value is missing or is another option. +pub(crate) fn needs_value(command: &str, name: &str, value: Option<&str>) -> String { + match value { + Some(v) if !v.is_empty() => { + format!("{command}: {name} needs a value and `{v}` is another option; write `./{v}` for a file of that name") + } + _ => format!("{command}: {name} needs a value"), + } +} + +/// Refuse unknown options and malformed confinement flags before any file is read. +pub(crate) fn validate_options(command: &str, args: &[String], specs: &[OptionSpec]) -> Result<(), CliError> { + if specs.iter().any(|spec| spec.name == "--root") { + // `flag` searches every token, including another option's value. Match that lookup so + // a malformed confinement flag cannot be hidden there and fall back to no root. + let mut root_seen = false; + for (i, _) in args.iter().enumerate().filter(|(_, arg)| arg.as_str() == "--root") { + if root_seen { + return Err(format!("{command}: --root may only be supplied once").into()); + } + root_seen = true; + if args.get(i + 1).is_none_or(|value| value.is_empty() || value.starts_with("--")) { + return Err(format!("{command}: --root needs a directory value").into()); + } + } + } + let mut i = 0; + while let Some(arg) = args.get(i) { + if let Some(name) = arg.strip_prefix("--") { + let Some(spec) = specs.iter().find(|spec| spec.name == arg) else { + return Err(format!("{command}: unknown option --{name}").into()); + }; + // A file's name is not another option, which also closes the gap the walk left for + // those: an unknown option in that value position was skipped along with it. Other + // values, missing or malformed, are left to the command, which reports them in its + // own terms (`text` and `extract` explain what a page value must be). + i += if spec.takes_value { + let value = args.get(i + 1).map(String::as_str); + if !can_be_path_value(spec.name, value) { + return Err(needs_value(command, spec.name, value).into()); + } + 2 + } else { + 1 + }; + } else { + i += 1; + } + } + Ok(()) +} + +pub(crate) const INFO_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--password", takes_value: true }]; + +pub(crate) const AUDIT_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--password", takes_value: true }]; + +pub(crate) const TEXT_OPTIONS: &[OptionSpec] = + &[OptionSpec { name: "--page", takes_value: true }, OptionSpec { name: "--password", takes_value: true }]; + +pub(crate) const COMBINE_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--out", takes_value: true }]; + +pub(crate) const EXTRACT_OPTIONS: &[OptionSpec] = &[ + OptionSpec { name: "--out", takes_value: true }, + OptionSpec { name: "--pages", takes_value: true }, + OptionSpec { name: "--password", takes_value: true }, +]; + +pub(crate) const SPLIT_OPTIONS: &[OptionSpec] = &[ + OptionSpec { name: "--every", takes_value: true }, + OptionSpec { name: "--before", takes_value: true }, + OptionSpec { name: "--out-dir", takes_value: true }, + OptionSpec { name: "--password", takes_value: true }, +]; + +pub(crate) const RENDER_OPTIONS: &[OptionSpec] = &[ + OptionSpec { name: "--page", takes_value: true }, + OptionSpec { name: "--dpi", takes_value: true }, + OptionSpec { name: "--out", takes_value: true }, + OptionSpec { name: "--password", takes_value: true }, +]; + +pub(crate) const CHECK_ONE_OPTIONS: &[OptionSpec] = + &[OptionSpec { name: "--dpi", takes_value: true }, OptionSpec { name: "--edit", takes_value: false }]; + +pub(crate) const CHECK_OPTIONS: &[OptionSpec] = &[ + OptionSpec { name: "--timeout", takes_value: true }, + OptionSpec { name: "--dpi", takes_value: true }, + OptionSpec { name: "--json", takes_value: true }, +]; + +pub(crate) 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 }, +]; + +pub(crate) const UI_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--control", takes_value: true }, OptionSpec { name: "--out", takes_value: true }]; + +#[cfg(feature = "mcp")] +pub(crate) const MCP_OPTIONS: &[OptionSpec] = + &[OptionSpec { name: "--root", takes_value: true }, OptionSpec { name: "--compact", takes_value: false }]; + +/// The arguments that are not options or their values. Which tokens are values is the spec's +/// answer: skipping the one after every option made `check-one --edit file.pdf` report "missing +/// file", the file having been read as `--edit`'s value. +pub(crate) fn positional<'a>(args: &'a [String], specs: &[OptionSpec]) -> Vec<&'a str> { + let mut out = Vec::new(); + let mut i = 0; + while let Some(a) = args.get(i) { + match specs.iter().find(|spec| spec.name == a.as_str()) { + Some(spec) if spec.takes_value => i += 2, + // An option that takes no value, and an unknown one, which `validate_options` has + // already refused by the time this runs. + Some(_) => i += 1, + None if a.starts_with("--") => i += 1, + None => { + out.push(a.as_str()); + i += 1; + } + } + } + out +} diff --git a/apps/pdfcraft-cli/src/run.rs b/apps/pdfcraft-cli/src/run.rs new file mode 100644 index 0000000000..0726087d9d --- /dev/null +++ b/apps/pdfcraft-cli/src/run.rs @@ -0,0 +1,188 @@ +#[cfg(feature = "mcp")] +use super::options::MCP_OPTIONS; +use super::options::{RUN_OPTIONS, flag, positional, validate_options}; +#[cfg(feature = "mcp")] +use super::{Arc, Path, Write, automation_args}; +use super::{CliError, stdout_line}; + +// ---- automation -------------------------------------------------------------------------------- + +pub(crate) fn automation(args: &[String]) -> Result { + let a = pdfcraft_automation::Automation::new(); + match flag(args, "--root") { + Some(root) => a.with_root(root).map_err(|e| format!("--root {root}: {e}")), + None => Ok(a), + } +} + +pub(crate) fn tools() -> Result<(), CliError> { + let list: Vec = pdfcraft_automation::tools() + .iter() + .map(|t| serde_json::json!({ "name": t.name, "description": t.description, "read_only": t.read_only, "command": t.command, "input_schema": t.input_schema })) + .collect(); + stdout_line(format_args!("{}", serde_json::to_string_pretty(&list).unwrap_or_default()))?; + Ok(()) +} + +/// Print a tool's result: JSON as JSON; images go to `--out` (or are summarised). The image is +/// written like a tool's own output, so `--root` confines it too. +pub(crate) fn print_output( + auto: &pdfcraft_automation::Automation, + content: Vec, + out: Option<&str>, +) -> Result<(), CliError> { + for c in content { + match c { + pdfcraft_automation::Content::Json(v) => stdout_line(format_args!("{}", serde_json::to_string_pretty(&v).unwrap_or_default()))?, + pdfcraft_automation::Content::Png { data, width, height } => match out { + Some(path) => { + let written = auto.write_output(path, &data).map_err(|e| e.to_string())?; + stdout_line(format_args!("{}", serde_json::json!({ "image": written.to_string_lossy(), "width": width, "height": height })))?; + } + 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))) +} + +pub(crate) 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))?; + print_output(&auto, content, step["out"].as_str()).map_err(|e| match e { + CliError::Message(message) => CliError::Message(format!("step {} ({tool}): {message}", i + 1)), + stdout => stdout, + })?; + } + return Ok(()); + } + let tool = *positional(args, RUN_OPTIONS).first().ok_or("run: missing tool name (see `pdfcraft-cli tools`)")?; + let mut obj = serde_json::Map::new(); + for kv in positional(args, RUN_OPTIONS).iter().skip(1) { + let (k, v) = kv.split_once('=').ok_or(format!("run: expected key=value, got {kv:?}"))?; + let value = serde_json::from_str(v).unwrap_or_else(|_| serde_json::Value::String(v.to_string())); + obj.insert(k.to_string(), value); + } + let content = auto.call(tool, &serde_json::Value::Object(obj)).map_err(|e| e.to_string())?; + print_output(&auto, content, flag(args, "--out")) +} + +/// Take `--automation-read-root` / `--automation-write-root` (each `--flag DIR` or `--flag=DIR`, +/// once) out of `args`, and open the roots, which must be existing folders. Returns the arguments +/// that are left. A malformed root flag is an error, never a root silently left out. +#[cfg(feature = "mcp")] +fn automation_roots(command: &str, args: &[String]) -> Result<(Option>, Vec), String> { + let mut found = automation_args::AutomationArgs::new(None, None); + let mut left = Vec::new(); + let mut it = args.iter().cloned(); + while let Some(arg) = it.next() { + if !found.consume(&arg, &mut it).map_err(|e| format!("{command}: {e}"))? { + left.push(arg); + } + } + let roots = pdfcraft_automation::file_access::AutomationRoots::new(found.read_root.as_deref(), found.write_root.as_deref()) + .map_err(|e| format!("{command}: {e}"))?; + Ok((roots, left)) +} + +#[cfg(feature = "mcp")] +pub(crate) fn mcp(args: &[String]) -> Result<(), CliError> { + let (roots, args) = automation_roots("mcp", args)?; + let args = args.as_slice(); + validate_options("mcp", args, MCP_OPTIONS)?; + let compact = args.iter().any(|a| a == "--compact"); + let mut auto = automation(args)?; + if let Some(roots) = roots { + let show = |root: Option<&Path>| root.map_or_else(|| "none".to_string(), |r| r.display().to_string()); + let _ = writeln!( + std::io::stderr().lock(), + "pdfcraft-cli: files confined: read root {}, write root {}", + show(roots.read_root()), + show(roots.write_root()) + ); + auto = auto.with_automation_roots(Some(roots)); + } + let mut server = pdfcraft_automation::mcp::McpServer::new(auto).with_compact(compact); + let _ = writeln!( + std::io::stderr().lock(), + "pdfcraft-cli: MCP server on stdio (protocol {}{}); close stdin to stop", + pdfcraft_automation::mcp::PROTOCOL_VERSIONS[0], + if compact { ", compact tool list" } else { "" } + ); + server.serve(std::io::stdin().lock(), std::io::stdout().lock()).map_err(|e| CliError::Message(e.to_string())) +} diff --git a/apps/pdfcraft-cli/src/ui.rs b/apps/pdfcraft-cli/src/ui.rs new file mode 100644 index 0000000000..dc419171b0 --- /dev/null +++ b/apps/pdfcraft-cli/src/ui.rs @@ -0,0 +1,131 @@ +use super::options::{UI_OPTIONS, flag, positional, validate_options}; +use super::{CliError, Duration, stdout_line}; + +// ---- UI control channel client ----------------------------------------------------------------- + +/// One request to a running app's control channel (`pdfcraft --control FILE`). +pub(crate) fn ui(args: &[String]) -> Result<(), CliError> { + validate_options("ui", args, UI_OPTIONS)?; + use std::io::{BufRead, BufReader, Write as _}; + let file = flag(args, "--control").ok_or("ui: missing --control FILE (start the app with `pdfcraft --control FILE`)")?; + let info = read_control_file(file)?; + let port = info["port"].as_u64().ok_or(format!("{file}: no port"))?; + // Refused rather than truncated, which would quietly reach some other port. + let port = u16::try_from(port).map_err(|_| format!("{file}: {port} is not a port number"))?; + let token = info["token"].as_str().ok_or(format!("{file}: no token"))?; + let pos = positional(args, UI_OPTIONS); + let method = *pos.first().ok_or("ui: missing method (state, inspect, click, drag, type, key, command, commands, set, open, screenshot)")?; + let method = if method.starts_with("ui.") { method.to_string() } else { format!("ui.{method}") }; + let mut params = serde_json::Map::new(); + for kv in pos.iter().skip(1) { + let (k, v) = kv.split_once('=').ok_or(format!("ui: expected key=value, got {kv:?}"))?; + params.insert(k.to_string(), serde_json::from_str(v).unwrap_or_else(|_| serde_json::Value::String(v.to_string()))); + } + let stream = + std::net::TcpStream::connect(("127.0.0.1", port)).map_err(|e| format!("can't reach the app on port {port}: {e} (is it still running?)"))?; + stream.set_read_timeout(Some(Duration::from_secs(40))).map_err(|e| e.to_string())?; + let mut write = stream.try_clone().map_err(|e| e.to_string())?; + let mut lines = BufReader::new(stream).lines(); + let mut rpc = |id: u64, method: &str, params: serde_json::Value| -> Result { + writeln!(write, "{}", serde_json::json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params })).map_err(|e| e.to_string())?; + let line = lines.next().ok_or("the app closed the connection")?.map_err(|e| e.to_string())?; + let reply: serde_json::Value = serde_json::from_str(&line).map_err(|e| e.to_string())?; + match reply.get("error") { + Some(e) => Err(e["message"].as_str().unwrap_or("error").to_string()), + None => Ok(reply["result"].clone()), + } + }; + rpc(1, "auth", serde_json::json!({ "token": token }))?; + let mut result = rpc(2, &method, serde_json::Value::Object(params))?; + if let (Some(out), Some(data)) = (flag(args, "--out"), result.get("png_base64").and_then(|d| d.as_str())) { + use base64::Engine as _; + let png = base64::engine::general_purpose::STANDARD.decode(data).map_err(|e| e.to_string())?; + std::fs::write(out, png).map_err(|e| format!("{out}: {e}"))?; + result["png_base64"] = serde_json::json!(format!("written to {out}")); + } + stdout_line(format_args!("{}", serde_json::to_string_pretty(&result).unwrap_or_default()))?; + Ok(()) +} + +/// Most a control file may hold. The app writes under 100 bytes. +const CONTROL_FILE_MAX: u64 = 64 * 1024; + +const CONTROL_FILE_ADVICE: &str = + "Start the app with a control file in a folder only you can write, such as `pdfcraft --control ~/.pdfcraft-control.json`"; + +/// Read the file written by `pdfcraft --control FILE`. +/// +/// It names the port that receives the token and every command, typed text included, so it must +/// be the user's own. In a shared folder such as `/tmp`, another local user can create the file +/// first; the app then can't write it, and the CLI would talk to the other user's listener. +/// Refused: a symbolic link, anything but a regular file, and on Unix a file owned by someone +/// else or open to other users (the app writes it with mode 600). +fn read_control_file(file: &str) -> Result { + use std::io::Read as _; + let at_path = std::fs::symlink_metadata(file).map_err(|e| format!("{file}: {e}"))?; + if at_path.file_type().is_symlink() { + return Err(format!("{file}: is a symbolic link, which could point at another user's file. {CONTROL_FILE_ADVICE}")); + } + if !at_path.is_file() { + return Err(format!("{file}: not a regular file. {CONTROL_FILE_ADVICE}")); + } + let f = std::fs::File::open(file).map_err(|e| format!("{file}: {e}"))?; + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + // Checked on the open file, so nothing swapped in after `symlink_metadata` gets through. + let opened = f.metadata().map_err(|e| format!("{file}: {e}"))?; + if (opened.dev(), opened.ino()) != (at_path.dev(), at_path.ino()) { + return Err(format!("{file}: was replaced while being opened. {CONTROL_FILE_ADVICE}")); + } + if let Some(problem) = control_file_problem(opened.uid(), opened.mode(), current_uid()) { + return Err(format!("{file}: {problem}. {CONTROL_FILE_ADVICE}")); + } + } + let mut text = String::new(); + f.take(CONTROL_FILE_MAX.saturating_add(1)).read_to_string(&mut text).map_err(|e| format!("{file}: {e}"))?; + if u64::try_from(text.len()).unwrap_or(u64::MAX) > CONTROL_FILE_MAX { + return Err(format!("{file}: too large for a control file (over {} KiB)", CONTROL_FILE_MAX / 1024)); + } + serde_json::from_str(&text).map_err(|e| format!("{file}: {e}")) +} + +/// Why user `me` can't trust a control file with this owner and mode, if they can't. +#[cfg(any(test, unix))] +fn control_file_problem(owner: u32, mode: u32, me: u32) -> Option { + if owner != me { + return Some(format!("owned by another user (uid {owner}; you are uid {me}), who may be the one listening for your commands")); + } + let permissions = mode & 0o7777; + (permissions & 0o077 != 0).then(|| format!("other users have access to it (mode {permissions:o}; the app writes it with mode 600)")) +} + +/// The effective user id: the owner of the files this process creates, and so of a control file +/// the app wrote for this user. std has no `geteuid` and this crate forbids `unsafe`; rustix's is +/// safe and can't fail. +#[cfg(unix)] +fn current_uid() -> u32 { + rustix::process::geteuid().as_raw() +} + +#[cfg(test)] +mod tests { + use super::control_file_problem; + + #[test] + fn a_control_file_must_be_yours_and_private() { + // Regular file type bits (0o100000) don't matter, only the owner and the permission bits. + assert_eq!(control_file_problem(1000, 0o100600, 1000), None); + assert_eq!(control_file_problem(1000, 0o600, 1000), None); + assert_eq!(control_file_problem(1000, 0o400, 1000), None); + let other = control_file_problem(1001, 0o600, 1000).unwrap_or_default(); + assert!(other.contains("owned by another user") && other.contains("1001"), "{other}"); + // Root gets no exception: a file a user planted is still theirs. + assert!(control_file_problem(1000, 0o600, 0).is_some_and(|p| p.contains("owned by another user"))); + for mode in [0o644, 0o640, 0o604, 0o660, 0o606, 0o620, 0o602, 0o610, 0o601, 0o100666] { + let open = control_file_problem(1000, mode, 1000).unwrap_or_default(); + assert!(open.contains("other users"), "{mode:o}: {open}"); + } + assert!(control_file_problem(1000, 0o100644, 1000).unwrap_or_default().contains("mode 644")); + } +} diff --git a/apps/pdfcraft-cli/tests/it/text.rs b/apps/pdfcraft-cli/tests/it/text.rs index 3d39a9e284..b7fb2511b7 100644 --- a/apps/pdfcraft-cli/tests/it/text.rs +++ b/apps/pdfcraft-cli/tests/it/text.rs @@ -243,4 +243,40 @@ mod page_argument_tests { assert_eq!(std::fs::read(&source).unwrap(), bytes); } } + + #[test] + fn text_clips_glyphs_outside_the_crop_box() { + let dir = PageDir::new(); + let path = dir.0.join("cropped.pdf"); + let body = "BT /F1 12 Tf 20 260 Td (Outside header) Tj 0 -110 Td (Visible body) Tj 0 -120 Td (Outside footer) Tj ET"; + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 200 300] >>".to_vec(), + b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /CropBox [0 100 200 200] /Contents 5 0 R /Resources << /Font << /F1 3 0 R >> >> >>".to_vec(), + format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()).into_bytes(), + ]; + 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", i + 1).as_bytes()); + pdf.extend_from_slice(o); + pdf.extend_from_slice(b"\nendobj\n"); + } + 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(&path, pdf).unwrap(); + + for args in [vec![], vec!["--page", "1"]] { + let out = Command::new(BIN).arg("text").arg(&path).args(&args).output().unwrap(); + assert!(out.status.success(), "{args:?}: {out:?}"); + assert!(out.stderr.is_empty(), "{args:?}: {}", String::from_utf8_lossy(&out.stderr)); + assert_eq!(String::from_utf8(out.stdout).unwrap().trim(), "Visible body", "{args:?}"); + } + } } diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs index ee8ae152cc..5bc5c5c372 100644 --- a/apps/pdfcraft/src/main.rs +++ b/apps/pdfcraft/src/main.rs @@ -1,6 +1,8 @@ //! PdfCraft desktop app. //! //! Usage: `pdfcraft [options] [files…]` (`-h` / `--help` prints a short version of this, [`USAGE`]). +//! `--two-paged` opens each command-line PDF in noncontinuous two-page view, without changing defaults. +//! An explicit `--layout` takes precedence for the active document, regardless of argument order. //! `--create-images [images…]` stages the images in one PDF and asks for the page DPI. //! //! View options (applied after the files open; also the seed of the UI control channel): @@ -8,7 +10,7 @@ //! --theme light|dark|system --language auto| --mode all|read|edit|convert|sign --tool --left open|closed //! --organize on --fields on --dialog properties|shortcuts|about --palette --home on //! --cover on|off --default-layout single|continuous|two-up|two-page --default-continuous on|off --default-zoom fit-width|fit-page| --always-default-view on|off -//! --page-gaps on|off --default-page-gaps on|off +//! --page-gaps on|off --default-page-gaps on|off --recent-files 0..50 //! --combine-view grid|list --combine-zoom 60..200` //! //! `--new-window` opens a window of its own. Without it, on Windows, a launch that only names files @@ -40,6 +42,7 @@ mod motion; mod reopen; mod settings; mod single_instance; +mod startup; mod text_scale; #[cfg(test)] #[path = "windows_manifest.rs"] @@ -63,6 +66,7 @@ Usage: pdfcraft [options] [files…] Opens the files in the PdfCraft desktop app. Options: + --two-paged open command-line PDFs in noncontinuous two-page view --new-window open a window of its own instead of passing the files to a running PdfCraft --create-images [images…] put the images in one PDF and ask for the page DPI --control enable the UI control channel (pdfcraft-cli ui --control …) @@ -196,7 +200,7 @@ fn rejoin_split_paths(args: Vec, exists: impl Fn(&str) -> bool) -> Vec eframe::Result { // Windows: the update helper is a copy of this program started with `--finish-update`. #[cfg(windows)] - if let Some(result) = craft_update::self_update::finish(&craft_update::suite::PDFCRAFT, &std::env::args_os().skip(1).collect::>()) { + if let Some(result) = craft_update::self_update::finish(&pdfcraft_ui_egui::UPDATE_APP, &std::env::args_os().skip(1).collect::>()) { std::process::exit(i32::from(result.is_err())); } // First, so the panic hook and every start-up warning are recorded (`logging`). @@ -218,28 +222,16 @@ fn main() -> eframe::Result { let _ = std::io::Write::write_fmt(&mut std::io::stderr(), format_args!("pdfcraft: {report}\n")); } })); - let mut files = Vec::new(); - let mut options: Vec<(String, String)> = Vec::new(); - let mut control_file: Option = None; - let mut create_images = false; - let mut new_window = false; - let mut args = std::env::args().skip(1); - while let Some(a) = args.next() { - match a.as_str() { - "--version" => return print_and_exit(&format!("pdfcraft {}", pdfcraft_engine::build_info::version())), - // Before any window or log file, so a scripted call returns (#4076). - "-h" | "--help" => return print_and_exit(USAGE), - "--control" => control_file = args.next(), - "--create-images" => create_images = true, - "--new-window" => new_window = true, - flag if flag.starts_with("--") => { - let value = args.next().unwrap_or_default(); - options.push((flag.trim_start_matches("--").to_string(), value)); - } - _ => files.push(path_from_arg(a)), - } + let mut startup = startup::Arguments::parse(std::env::args().skip(1)); + if startup.version { + return print_and_exit(&format!("pdfcraft {}", pdfcraft_engine::build_info::version())); + } + if startup.help { + return print_and_exit(USAGE); } - let files = rejoin_split_paths(files, |p| std::path::Path::new(p).is_file()); + startup.files = rejoin_split_paths(startup.files, |p| std::path::Path::new(p).is_file()); + let may_hand_off = startup.may_hand_off(); + let startup::Arguments { files, options, control_file, create_images, two_paged, .. } = startup; let integrated = cfg!(target_os = "macos"); // GNOME on Wayland leaves the window frame to the app (src/decorations.rs). let window_controls = if cfg!(any(target_os = "macos", target_os = "windows")) { None } else { decorations::app_drawn() }; @@ -260,7 +252,6 @@ fn main() -> eframe::Result { // 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; // Absolute for this instance too: links relative to a document resolve against its folder, // never the working directory. let files = single_instance::absolute(&files); @@ -270,7 +261,7 @@ fn main() -> eframe::Result { single_instance::Claim::Alone => None, }; let choice = renderer_choice(std::env::var("PDFCRAFT_RENDERER").ok().as_deref()); - let launch = Launch { files, options, control_file, create_images, integrated, window_controls }; + let launch = Launch { files, options, control_file, create_images, integrated, window_controls, two_paged }; // Finder, Open With and the Dock deliver files as Apple events, not arguments; catch the one // that launched us as well as later ones. Lives until the event loop returns. #[cfg(target_os = "macos")] @@ -380,6 +371,7 @@ struct Launch { integrated: bool, /// The window buttons the app draws, when the desktop leaves the frame to it. window_controls: Option, + two_paged: bool, } /// Whether the window is a native Wayland one (not X11 or XWayland). @@ -400,7 +392,7 @@ fn app_creator<'a>( instance: Option<&'a single_instance::Server>, #[cfg(target_os = "macos")] apple_events: &'a apple_events::AppleEvents, ) -> eframe::AppCreator<'a> { - let Launch { files, options, control_file, create_images, integrated, window_controls } = launch; + let Launch { files, options, control_file, create_images, integrated, window_controls, two_paged } = launch; Box::new(move |cc| { started.set(true); let mut app = PdfCraftApp::new(); @@ -467,6 +459,7 @@ fn app_creator<'a>( &[("folder", &w.folder.display().to_string()), ("error", &w.error)], ); } + let defer_layout = two_paged && !files.is_empty() && !create_images; if create_images { if let Err(e) = app.begin_image_import_paths(&files) { app.notify(e); @@ -475,12 +468,11 @@ fn app_creator<'a>( // With the preference on, last session's files come back first; files named on // the command line open after them, in front (#442). app.reopen_last_files(&files); - for f in files { - app.open_path(&f); - } + startup::open_files(&mut app, &files, two_paged); } for (k, v) in options { - if let Err(e) = app.set_option(&k, &v) { + let result = if defer_layout && k == "layout" { app.set_launch_layout(&v) } else { app.set_option(&k, &v) }; + if let Err(e) = result { log::warn!("--{k} {v}: {e}"); } } @@ -802,705 +794,4 @@ fn pick_adapter(adapters: &[(u32, u32, eframe::wgpu::DeviceType)], displays: &[D } #[cfg(test)] -mod tests { - /// The lockfile package block for `name`, or a panic naming the missing package. - fn locked_package(root: &std::path::Path, name: &str) -> String { - let lock = std::fs::read_to_string(root.join("Cargo.lock")).expect("Cargo.lock"); - lock.split("[[package]]") - .find(|p| p.contains(&format!("\nname = \"{name}\"\n"))) - .map(str::to_owned) - .unwrap_or_else(|| panic!("{name} is not in Cargo.lock")) - } - - /// Interim graph check for the window stack while the reviewed fork revision is not yet - /// published. The old `vendor/winit` / `vendor/egui-wgpu` path patches were removed from the - /// published manifest; local integration supplies the reviewed worktrees through an external - /// Cargo config patch. A git source must name the public storytold fork; a path source is the - /// local integration graph. Behaviour (surface fitting, error scopes, scroll phases) is tested - /// directly elsewhere; the retained vendor copies keep provenance until the fork revision is - /// pinned. No public revision is invented here. - fn assert_window_stack_graph(root: &std::path::Path, name: &str, version: &str) -> String { - let package = locked_package(root, name); - assert!(package.contains(&format!("\nversion = \"{version}\"\n")), "{name} must resolve to {version}: {package}"); - if let Some(source) = package.lines().find_map(|line| line.strip_prefix("source = ")) { - assert!(source.contains("github.com/storytold/"), "{name} must come from the reviewed storytold fork once published, not {source}"); - assert!( - source.split(|c: char| !c.is_ascii_hexdigit()).any(|part| part.len() == 40), - "{name} must pin the fork's 40-character revision, not {source}" - ); - } - // A path patch has no `source =` in Cargo.lock. The local integration config is the only - // allowed path source; a machine-local path must never be in the published manifest, - // which the root-manifest assertion below checks. A path entry carries no remote identity - // to verify, so this test does not claim the resolved code is the published fork. - let manifest = std::fs::read_to_string(root.join("Cargo.toml")).expect("root Cargo.toml"); - assert!(!manifest.contains(&format!("{name} = {{ path = \"vendor/")), "vendor/{name} must not be a published path patch"); - package - } - - use pdfcraft_ui_egui::control::Endpoint; - - fn endpoint() -> std::io::Result { - Ok(Endpoint { port: 4321, token: "0123456789abcdef".into() }) - } - - /// Tests that open a real GPU device take this first, so only one of them is inside the - /// graphics libraries at a time. The test threads otherwise run them together, and on - /// FreeBSD's CI machine the test process then died with SIGSEGV, in about half the runs: - /// the first device was still being set up when the second test began its own. The UI - /// tests take turns the same way (`crates/ui-egui/tests/it/main.rs`). Hold it for the whole - /// test (declared before the instance, so it's released after the device is dropped). - static GPU: std::sync::Mutex<()> = std::sync::Mutex::new(()); - - fn gpu() -> std::sync::MutexGuard<'static, ()> { - // A test that panicked while holding it leaves nothing to clean up. - GPU.lock().unwrap_or_else(std::sync::PoisonError::into_inner) - } - - #[test] - fn a_control_channel_that_cant_be_published_stops_the_app() { - let dir = std::env::temp_dir().join(format!("pdfcraft-app-control-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let path = |name: &str| dir.join(name).to_str().unwrap().to_owned(); - - // A folder in the way stands in for a file another user created first: the write fails. - std::fs::create_dir(dir.join("taken.json")).unwrap(); - let err = super::open_control_channel(&path("taken.json"), endpoint()).unwrap_err(); - assert!(err.contains("--control") && err.contains("taken.json") && err.contains("only you can write"), "{err}"); - // No listener: nothing to publish. - let err = super::open_control_channel(&path("pc.json"), Err(std::io::Error::other("no loopback"))).unwrap_err(); - assert!(err.contains("no loopback"), "{err}"); - assert!(!dir.join("pc.json").exists()); - - super::open_control_channel(&path("pc.json"), endpoint()).unwrap(); - let written: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(dir.join("pc.json")).unwrap()).unwrap(); - assert_eq!((written["port"].as_u64(), written["token"].as_str()), (Some(4321), Some("0123456789abcdef"))); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn path_from_arg_decodes_file_uris() { - assert_eq!(super::path_from_arg("file:///home/alice/report.pdf".to_string()), "/home/alice/report.pdf"); - assert_eq!(super::path_from_arg("file:///home/alice/my%20report.pdf".to_string()), "/home/alice/my report.pdf"); - assert_eq!(super::path_from_arg("file://localhost/tmp/a%C3%A9.pdf".to_string()), "/tmp/aé.pdf"); - // Malformed or truncated escapes are kept as written; bytes that aren't UTF-8 keep the URI. - assert_eq!(super::path_from_arg("file:///tmp/100%.pdf".to_string()), "/tmp/100%.pdf"); - assert_eq!(super::path_from_arg("file:///tmp/a%2".to_string()), "/tmp/a%2"); - assert_eq!(super::path_from_arg("file:///tmp/%zz%".to_string()), "/tmp/%zz%"); - assert_eq!(super::path_from_arg("file:///tmp/%FF.pdf".to_string()), "file:///tmp/%FF.pdf"); - } - - #[test] - fn unquoted_paths_with_spaces_are_rejoined() { - let have = ["P:\\Archive\\00042A26 Contract.PDF", "C:\\a b c.pdf", "C:\\a.pdf", "C:\\x y.pdf"]; - let exists = |p: &str| have.contains(&p); - let args = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::>(); - // The case an office program produced: one path, split at its space. - assert_eq!( - super::rejoin_split_paths(args(&["P:\\Archive\\00042A26", "Contract.PDF"]), exists), - args(&["P:\\Archive\\00042A26 Contract.PDF"]) - ); - // The longest run wins; files that exist on their own are left alone. - assert_eq!(super::rejoin_split_paths(args(&["C:\\a", "b", "c.pdf", "C:\\a.pdf"]), exists), args(&["C:\\a b c.pdf", "C:\\a.pdf"])); - assert_eq!(super::rejoin_split_paths(args(&["C:\\a.pdf", "C:\\x", "y.pdf"]), exists), args(&["C:\\a.pdf", "C:\\x y.pdf"])); - // Nothing to join: kept as given, so the missing file is still reported by name. - assert_eq!(super::rejoin_split_paths(args(&["missing.pdf", "other"]), exists), args(&["missing.pdf", "other"])); - assert!(super::rejoin_split_paths(Vec::new(), exists).is_empty()); - } - - #[test] - fn path_from_arg_leaves_plain_paths_and_other_schemes_alone() { - assert_eq!(super::path_from_arg("report.pdf".to_string()), "report.pdf"); - assert_eq!(super::path_from_arg("/home/alice/report.pdf".to_string()), "/home/alice/report.pdf"); - assert_eq!(super::path_from_arg("https://example.com/report.pdf".to_string()), "https://example.com/report.pdf"); - assert_eq!(super::path_from_arg("file://server/share/a.pdf".to_string()), "file://server/share/a.pdf"); - assert_eq!(super::path_from_arg("file://".to_string()), "file://"); - } - - #[test] - fn opengl_is_the_fallback_unless_a_renderer_was_chosen() { - use super::{RendererChoice, native_options, renderer_choice, retry_with_gl}; - assert_eq!(renderer_choice(None), RendererChoice::Auto); - assert_eq!(renderer_choice(Some("")), RendererChoice::Auto); - assert_eq!(renderer_choice(Some("vulkan")), RendererChoice::Auto); - assert_eq!(renderer_choice(Some(" WGPU ")), RendererChoice::Wgpu); - for gl in ["gl", "OpenGL", "glow"] { - assert_eq!(renderer_choice(Some(gl)), RendererChoice::Gl); - } - // Retried with OpenGL only when wgpu failed to start by default, never after the app ran. - assert!(retry_with_gl(RendererChoice::Auto, false)); - assert!(!retry_with_gl(RendererChoice::Auto, true)); - assert!(!retry_with_gl(RendererChoice::Wgpu, false)); - assert!(!retry_with_gl(RendererChoice::Gl, false)); - // Each run states its renderer rather than relying on eframe's default. - assert_eq!(native_options(false, false, eframe::Renderer::Wgpu).renderer, eframe::Renderer::Wgpu); - assert_eq!(native_options(false, false, eframe::Renderer::Glow).renderer, eframe::Renderer::Glow); - } - - #[test] - fn the_desktop_frame_is_turned_off_only_when_the_app_draws_one() { - use super::native_options; - assert_eq!(native_options(false, false, eframe::Renderer::Glow).viewport.decorations, None); - assert_eq!(native_options(false, true, eframe::Renderer::Glow).viewport.decorations, Some(false)); - } - - #[test] - fn a_software_renderer_is_shown_to_the_user() { - use eframe::wgpu::DeviceType; - // Issue #519: WARP or llvmpipe is announced in the app, not just in pdfcraft.log. - let mut app = super::PdfCraftApp::new(); - super::notify_software_renderer(&mut app, DeviceType::Cpu); - let notice = app.toast.as_ref().map(|(m, _)| m.as_str()).unwrap_or_default(); - assert!(notice.contains("without a graphics processor"), "{notice:?}"); - for gpu in [DeviceType::IntegratedGpu, DeviceType::DiscreteGpu, DeviceType::VirtualGpu, DeviceType::Other] { - let mut app = super::PdfCraftApp::new(); - super::notify_software_renderer(&mut app, gpu); - assert!(app.toast.is_none(), "{gpu:?}: {:?}", app.toast); - } - } - - #[test] - fn gpu_setup_avoids_vulkan_and_stretched_resizes_on_windows_and_prefers_low_power() { - let mut native = eframe::NativeOptions::default(); - super::configure_gpu(&mut native); - let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &native.wgpu_options.wgpu_setup else { - panic!("default setup creates its own instance") - }; - if std::env::var_os("WGPU_POWER_PREF").is_none() { - assert_eq!(setup.power_preference, eframe::wgpu::PowerPreference::LowPower); - } - if std::env::var_os("WGPU_VALIDATION_INDIRECT_CALL").is_none() { - let flags = setup.instance_descriptor.flags; - assert!(!flags.contains(eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL), "Intel Metal can't compile its shader: {flags:?}"); - } - if cfg!(target_os = "windows") && std::env::var_os("WGPU_BACKEND").is_none() { - let backends = setup.instance_descriptor.backends; - assert!(backends.contains(eframe::wgpu::Backends::DX12), "{backends:?}"); - assert!(!backends.contains(eframe::wgpu::Backends::VULKAN), "issue #37: {backends:?}"); - } - use eframe::wgpu::Dx12SwapchainKind::{DxgiFromHwnd, DxgiFromVisual}; - assert_eq!(super::dx12_presentation(None), DxgiFromVisual); - assert_eq!(super::dx12_presentation(Some(DxgiFromHwnd)), DxgiFromHwnd, "WGPU_DX12_PRESENTATION_SYSTEM still decides"); - assert_eq!(super::dx12_presentation(Some(DxgiFromVisual)), DxgiFromVisual); - let presentation = setup.instance_descriptor.backend_options.dx12.presentation_system; - if cfg!(target_os = "windows") { - // A composition visual unless WGPU_DX12_PRESENTATION_SYSTEM asks otherwise. - let asked = eframe::wgpu::Dx12SwapchainKind::from_env(); - assert_eq!(presentation, asked.unwrap_or(DxgiFromVisual), "issue #2738: resizing stretches a window swap chain"); - } else { - assert_eq!(presentation, eframe::wgpu::Dx12SwapchainKind::default().with_env(), "only Windows changes it"); - } - } - - #[test] - fn restored_hidpi_window_fits_the_adapters_surface_limit() { - // The Outlook launch crash: 3440 x 1369 saved points at 250% DPI requested this surface. - let (width, height) = (8600, 3423); - let required = eframe::wgpu::Limits::default(); - assert!(width > required.max_texture_dimension_2d); - let supported = eframe::wgpu::Limits { max_texture_dimension_2d: 16384, ..required.clone() }; - let enabled = super::surface_texture_limits(required, &supported); - assert!(width <= enabled.max_texture_dimension_2d); - assert!(height <= enabled.max_texture_dimension_2d); - } - - #[test] - fn surface_limits_preserve_other_requirements_and_never_overrequest() { - for required in [eframe::wgpu::Limits::default(), eframe::wgpu::Limits::downlevel_webgl2_defaults()] { - for extent in [4096, 8192, 16384] { - let supported = eframe::wgpu::Limits { - max_texture_dimension_2d: extent, - max_bind_groups: 8, - min_uniform_buffer_offset_alignment: 128, - min_storage_buffer_offset_alignment: 128, - ..required.clone() - }; - let enabled = super::surface_texture_limits(required.clone(), &supported); - let mut expected = required.clone(); - expected.max_texture_dimension_2d = extent; - assert_eq!(enabled, expected); - assert_eq!(enabled.max_texture_dimension_2d, supported.max_texture_dimension_2d); - assert!(enabled.check_limits(&supported)); - } - } - } - - #[test] - fn surface_limits_fit_v3d_shader_variable_limit() { - // Issue #2383: V3D supports 15 variables, below the default request of 16. - let required = eframe::wgpu::Limits::default(); - let supported = eframe::wgpu::Limits { max_texture_dimension_2d: 7680, max_inter_stage_shader_variables: 15, ..required.clone() }; - let enabled = super::surface_texture_limits(required.clone(), &supported); - assert!(enabled.check_limits(&supported), "unsupported device limits: {enabled:?}"); - let expected = eframe::wgpu::Limits { max_texture_dimension_2d: 7680, max_inter_stage_shader_variables: 15, ..required }; - assert_eq!(enabled, expected); - } - - #[test] - fn surface_limits_fit_downlevel_maxima_and_minimum_alignments() { - let required = eframe::wgpu::Limits::downlevel_webgl2_defaults(); - let supported = eframe::wgpu::Limits { - max_texture_dimension_2d: 2048, - max_bind_groups: 2, - max_vertex_attributes: 8, - min_uniform_buffer_offset_alignment: 512, - min_storage_buffer_offset_alignment: 512, - ..required.clone() - }; - let enabled = super::surface_texture_limits(required, &supported); - assert!(enabled.check_limits(&supported), "unsupported device limits: {enabled:?}"); - // Larger minimum alignments are weaker capabilities, unlike maximum limits. - assert_eq!(enabled, supported); - } - - #[test] - fn a_window_larger_than_the_gpu_limit_gets_a_surface_within_it() { - use eframe::egui_wgpu::winit::surface_fit; - // Issue #577: 3440 x 1369 points restored at 250% asked an 8192-pixel device for this. - let (width, height, scale) = surface_fit(8600, 3423, 8192); - assert_eq!((width, height), (8192, 3260)); - // Both sides shrink alike and egui draws at that factor: the whole window is drawn. - assert!((8600.0 * scale - 8192.0).abs() < 0.01, "{scale}"); - assert!((3423.0 * scale - height as f32).abs() < 1.0, "{scale}"); - // Within the limit nothing changes, up to and including the limit itself. - assert_eq!(surface_fit(8600, 3423, 16384), (8600, 3423, 1.0)); - assert_eq!(surface_fit(8192, 8192, 8192), (8192, 8192, 1.0)); - assert_eq!(surface_fit(0, 0, 8192), (0, 0, 1.0)); - // One side over the limit: the window as it would be stretched across monitors. - assert_eq!(surface_fit(8193, 600, 8192).0, 8192); - assert_eq!(surface_fit(600, 8193, 8192).1, 8192); - } - - #[test] - fn surface_fit_never_empties_or_overflows_a_surface() { - use eframe::egui_wgpu::winit::surface_fit; - for (w, h, max) in [ - (1, u32::MAX, 8192), - (u32::MAX, 1, 2048), - (u32::MAX, u32::MAX, 16384), - (0, u32::MAX, 8192), - (u32::MAX, 0, 8192), - (5, 7, 0), - (40_000, 3, 1), - ] { - let (fw, fh, scale) = surface_fit(w, h, max); - assert!(fw <= max.max(1) && fh <= max.max(1), "{w} x {h} in {max}: {fw} x {fh}"); - // A side that wasn't zero stays non-zero (`Surface::configure` rejects an empty one), - // and a zero side stays zero (egui-wgpu skips configuring it). - assert_eq!((fw == 0, fh == 0), (w == 0, h == 0), "{w} x {h} in {max}: {fw} x {fh}"); - assert!(scale.is_finite() && scale > 0.0 && scale <= 1.0, "{w} x {h} in {max}: {scale}"); - } - // The longer side lands exactly on the limit (rounding never leaves it a pixel short), - // and the shorter side keeps the window's proportions to within a pixel. - for max in [2048, 8192, 16384] { - for long in (max + 1..=max * 5).step_by(997).chain([max * 2, max * 4, u32::MAX]) { - for short in [1, 3, 600, max / 3, max - 1, max, long - 1, long] { - let (fw, fh, scale) = surface_fit(long, short, max); - assert_eq!(fw, max, "{long} x {short} in {max}"); - let expected = f64::from(short) * f64::from(max) / f64::from(long); - assert!((f64::from(fh) - expected).abs() <= 1.0, "{long} x {short} in {max}: {fh}"); - assert_eq!(surface_fit(short, long, max), (fh, fw, scale), "transposed"); - } - } - } - } - - /// Regression test: the lockfile resolves egui-wgpu 0.37.0 through an allowed source, the root manifest - /// does not path-patch the vendor copy, and that copy stays as provenance for the surface-fit patch. - #[test] - fn vendor_provenance_egui_wgpu_surface_fit_identity() { - // Issue #577: egui-wgpu configured a window's surface at the window's size, and - // `Surface::configure` panics when that's beyond the device's `max_texture_dimension_2d` - // (emilk/egui#8361). The reviewed egui 0.37 worktree carries E2 (`surface_fit`); the - // behavioural test above exercises it. The old 0.36 vendor copy is retained for - // provenance only and is no longer a published path patch, so reading it would not prove - // what the app runs. - let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); - assert_window_stack_graph(&root, "egui-wgpu", "0.37.0"); - assert!(root.join("vendor/egui-wgpu/src/winit.rs").exists(), "the vendor copy and its provenance stay until the fork revision is pinned"); - } - - #[test] - fn a_gpu_error_while_a_window_is_set_up_is_returned_not_a_panic() { - // Issue #519: on a 2015 Intel GPU, wgpu's GL backend couldn't configure the window's - // surface (`GpuWaitTimeout`), and wgpu's default error handler panicked before the app was - // created, so PdfCraft never got to retry with OpenGL. vendor/egui-wgpu configures a new - // window's surface inside `catch_errors`. A real device on PdfCraft's own GPU settings, - // and an error wgpu raises before anything reaches the driver: a texture one pixel wider - // than the device allows. - use eframe::wgpu; - let _gpu = gpu(); - let native = super::native_options(false, false, eframe::Renderer::Wgpu); - let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &native.wgpu_options.wgpu_setup else { - panic!("default setup creates its own instance") - }; - let instance = pollster::block_on(native.wgpu_options.wgpu_setup.new_instance()); - let options = wgpu::RequestAdapterOptions { power_preference: setup.power_preference, ..Default::default() }; - // CI runners have a software adapter (WARP, llvmpipe); a machine without any skips. - let Ok(adapter) = pollster::block_on(instance.request_adapter(&options)) else { - eprintln!("skipping: no GPU adapter on this machine"); - return; - }; - let Ok((device, _queue)) = pollster::block_on(adapter.request_device(&(setup.device_descriptor)(&adapter))) else { - eprintln!("skipping: the adapter gives no device"); - return; - }; - let texture = |width: u32| { - device.create_texture(&wgpu::TextureDescriptor { - label: Some("issue-519"), - size: wgpu::Extent3d { width, height: 1, depth_or_array_layers: 1 }, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format: wgpu::TextureFormat::Rgba8Unorm, - usage: wgpu::TextureUsages::TEXTURE_BINDING, - view_formats: &[], - }) - }; - let too_wide = device.limits().max_texture_dimension_2d.saturating_add(1); - let caught = pollster::block_on(eframe::egui_wgpu::winit::catch_errors(&device, || texture(too_wide))); - assert!(matches!(caught, Err(wgpu::Error::Validation { .. })), "{caught:?}"); - // The device stays usable, and nothing is left behind to catch later errors by mistake. - let fine = pollster::block_on(eframe::egui_wgpu::winit::catch_errors(&device, || texture(16))); - assert!(fine.is_ok(), "{fine:?}"); - assert_eq!(pollster::block_on(eframe::egui_wgpu::winit::catch_errors(&device, || 7)).ok(), Some(7)); - // Nested: the inner call keeps its own error, and the outer one still catches what follows. - let caught = pollster::block_on(eframe::egui_wgpu::winit::catch_errors(&device, || { - let inner = pollster::block_on(eframe::egui_wgpu::winit::catch_errors(&device, || texture(too_wide))); - assert!(inner.is_err(), "inner: {inner:?}"); - texture(too_wide) - })); - assert!(matches!(caught, Err(wgpu::Error::Validation { .. })), "outer: {caught:?}"); - } - - /// Regression test: the lockfile resolves egui-wgpu 0.37.0 through an allowed source, the root manifest - /// does not path-patch the vendor copy, and that copy stays as provenance for the catch-errors patch. - #[test] - fn vendor_provenance_egui_wgpu_catch_errors_identity() { - // Issue #519: E3 (`catch_errors`) is tested above against a real device. The reviewed 0.37 - // worktree carries it around the first configure of every new window's surface, and its - // error reaches eframe as `WgpuError` (eframe then returns `Error::Wgpu`, which `main` - // retries with OpenGL). Without it a failed configure panics before the app is created. - // `catch_errors` is exercised against a real device in the test above and below; the - // graph check makes sure the app runs the reviewed 0.37 worktree (the retained vendor - // copy is not read as proof of behaviour). - let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); - assert_window_stack_graph(&root, "egui-wgpu", "0.37.0"); - assert!(root.join("vendor/egui-wgpu/src/winit.rs").exists(), "the vendor copy and its provenance stay until the fork revision is pinned"); - } - - #[test] - fn pipeline_creation_error_is_returned_and_device_remains_usable() { - use eframe::{egui_wgpu, wgpu}; - let _gpu = gpu(); - let mut native = super::native_options(false, false, eframe::Renderer::Wgpu); - let egui_wgpu::WgpuSetup::CreateNew(setup) = &native.wgpu_options.wgpu_setup else { panic!("default setup creates its own instance") }; - let instance = pollster::block_on(native.wgpu_options.wgpu_setup.new_instance()); - let options = wgpu::RequestAdapterOptions { power_preference: setup.power_preference, ..Default::default() }; - let Ok(adapter) = pollster::block_on(instance.request_adapter(&options)) else { - assert!(std::env::var_os("PDFCRAFT_TEST_REQUIRE_GPU").is_none(), "a GPU adapter is required for this run"); - eprintln!("skipping pipeline regression: no GPU adapter"); - return; - }; - let (device, queue) = pollster::block_on(adapter.request_device(&(setup.device_descriptor)(&adapter))) - .expect("the selected adapter must provide a test device"); - native.wgpu_options.wgpu_setup = - egui_wgpu::WgpuSetup::Existing(egui_wgpu::WgpuSetupExisting { instance: instance.clone(), adapter, device, queue }); - // Three samples is invalid on every backend, so this reproduces a pipeline error - // without depending on a particular driver. The unpatched constructor panics. - for samples in [3, 1, 3, 1] { - let options = egui_wgpu::RendererOptions { msaa_samples: samples, ..Default::default() }; - let result = pollster::block_on(egui_wgpu::RenderState::create(&native.wgpu_options, &instance, None, options)); - if samples == 3 { - let error = result.err().expect("invalid pipeline must return an error"); - assert!(error.to_string().contains("egui_pipeline"), "{error}"); - } else { - assert!(result.is_ok(), "the device must remain usable: {:?}", result.err()); - } - } - } - - /// Regression test: winit resolves to 0.30.13 without a published vendor path patch, the retained copy - /// still carries the W1 Windows 11 monitor-scale fix, and its upstream base is recorded. - #[test] - fn winit_carries_the_windows_11_monitor_scale_fix() { - // Issue #324: winit 0.30.13 as released nudges a window dragged onto a monitor with another - // scale factor back onto the one it is leaving, so on Windows 11 it ends up on the wrong - // monitor, at the wrong size and scale. vendor/winit carries the fix from winit master. A - // dependency bump that resolves winit from crates.io again, or a re-vendored copy without - // the patch, would silently bring the bug back: re-apply the patch, or drop the copy once a - // winit 0.30 release has the fix (vendor/README.md). - // W1 (and W2–W13) were audited against the reviewed winit worktree: the vendor rows in - // `vendor/README.md` match the fork's PATCHES.md entries W1–W13. The vendor source is no - // longer a published path patch, so this checks the actual graph, not the old copy. - let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); - assert_window_stack_graph(&root, "winit", "0.30.13"); - let event_loop = std::fs::read_to_string(root.join("vendor/winit/src/platform_impl/windows/event_loop.rs")).unwrap(); - // The audited W1 hunk itself, not merely the file's presence: Windows 11 applies the - // rect Windows suggests, while Windows 10 keeps the old adjustment. - for marker in [ - "PdfCraft patch: from Windows 11 (build 22000) on, apply the rect Windows suggests", - "if !WIN10_BUILD_VERSION.is_some_and(|build| build < 22000) {", - "new_outer_rect = suggested_rect;", - ] { - assert!(event_loop.contains(marker), "vendor/winit lost the W1 monitor-scale patch: {marker}"); - } - let provenance = std::fs::read_to_string(root.join("vendor/winit/.cargo_vcs_info.json")).unwrap(); - assert!(provenance.contains("e9809ef54b18499bb4f2cac945719ecc2a61061b"), "vendor/winit keeps its upstream base provenance"); - } - - const NVIDIA: (u32, u32) = (0x10de, 0x2684); - const NVIDIA_3090: (u32, u32) = (0x10de, 0x2204); - const AMD_IGPU: (u32, u32) = (0x1002, 0x164e); - - fn adapters() -> Vec<(u32, u32, eframe::wgpu::DeviceType)> { - use eframe::wgpu::DeviceType; - vec![(NVIDIA.0, NVIDIA.1, DeviceType::DiscreteGpu), (AMD_IGPU.0, AMD_IGPU.1, DeviceType::IntegratedGpu), (0, 0, DeviceType::Cpu)] - } - - const INTEL_IGPU: (u32, u32) = (0x8086, 0xa780); - - fn monitor(pci: (u32, u32)) -> super::DisplayGpu { - super::DisplayGpu { pci, primary: false } - } - - fn panel(pci: (u32, u32)) -> super::DisplayGpu { - super::DisplayGpu { pci, primary: true } - } - - #[test] - fn pick_adapter_prefers_the_gpu_driving_the_monitors() { - // A desktop whose monitors are all on the discrete GPU: the integrated one shows black. - assert_eq!(super::pick_adapter(&adapters(), &[monitor(NVIDIA)], None), Some(0)); - // Issue #378: the same on Windows, where the Ryzen integrated GPU without a monitor crashed - // the display driver. Windows also says which display is the primary one. - assert_eq!(super::pick_adapter(&adapters(), &[panel(NVIDIA)], None), Some(0)); - } - - #[test] - fn pick_adapter_prefers_the_gpu_driving_the_primary_display() { - // A Windows desktop with a monitor on each GPU: the window opens on the primary display. - assert_eq!(super::pick_adapter(&adapters(), &[monitor(AMD_IGPU), panel(NVIDIA)], None), Some(0)); - assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)], None), Some(1)); - } - - #[test] - fn pci_ids_come_from_windows_device_ids() { - assert_eq!(super::pci_ids("PCI\\VEN_10DE&DEV_2204&SUBSYS_40421458&REV_A1"), Some(NVIDIA_3090)); - assert_eq!(super::pci_ids("PCI\\VEN_1002&DEV_164E&SUBSYS_88771043&REV_C1"), Some(AMD_IGPU)); - assert_eq!(super::pci_ids("pci\\ven_1002&dev_164e"), Some(AMD_IGPU)); - // Remote Desktop and other non-PCI display devices, and malformed ids. - assert_eq!(super::pci_ids("ROOT\\BasicDisplay\\0000"), None); - assert_eq!(super::pci_ids("PCI\\VEN_10DE&SUBSYS_40421458"), None); - assert_eq!(super::pci_ids("PCI\\VEN_10DE&DEV_ZZZZ"), None); - assert_eq!(super::pci_ids("VEN_&DEV_"), None); - assert_eq!(super::pci_ids(""), None); - } - - #[cfg(target_os = "windows")] - #[test] - fn windows_display_gpus_lists_pci_gpus_with_a_monitor() { - // Whatever this machine has: every entry is a PCI GPU, at most one drives the primary - // display, and no GPU is listed twice. (A headless CI runner may list none.) - let gpus = super::windows_display_gpus(); - assert!(gpus.iter().filter(|g| g.primary).count() <= 1, "{gpus:?}"); - for (i, g) in gpus.iter().enumerate() { - assert!(g.pci.0 != 0, "{gpus:?}"); - assert!(!gpus[..i].iter().any(|h| h.pci == g.pci), "{gpus:?}"); - } - } - - #[test] - fn pick_adapter_keeps_the_integrated_gpu_on_hybrid_laptops() { - // Issue #8: the panel is on the integrated GPU, an external monitor on the discrete one. - assert_eq!(super::pick_adapter(&adapters(), &[monitor(NVIDIA), panel(AMD_IGPU)], None), Some(1)); - assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)], None), Some(1)); - assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU)], None), Some(1)); - assert_eq!(super::pick_adapter(&adapters(), &[monitor(AMD_IGPU)], None), Some(1)); - } - - #[test] - fn pick_adapter_prefers_the_discrete_gpu_on_desktops_with_a_monitor_on_each() { - // Issue #445: one monitor on the Intel iGPU, one on the NVIDIA card, no built-in panel. - use eframe::wgpu::DeviceType; - let desktop = - [(INTEL_IGPU.0, INTEL_IGPU.1, DeviceType::IntegratedGpu), (NVIDIA.0, NVIDIA.1, DeviceType::DiscreteGpu), (0, 0, DeviceType::Cpu)]; - assert_eq!(super::pick_adapter(&desktop, &[monitor(INTEL_IGPU), monitor(NVIDIA)], None), Some(1)); - assert_eq!(super::pick_adapter(&desktop, &[monitor(NVIDIA), monitor(INTEL_IGPU)], None), Some(1)); - // A GPU driving no display still loses to one that does. - assert_eq!(super::pick_adapter(&adapters(), &[monitor(AMD_IGPU), monitor(INTEL_IGPU)], None), Some(1)); - } - - #[test] - fn pick_adapter_prefers_the_gpu_the_compositor_draws_with() { - // A hybrid laptop whose Wayland compositor runs on the discrete GPU (Hyprland with - // `AQ_DRM_DEVICES` naming it first): the integrated GPU drives the built-in panel, yet the - // compositor shows nothing it draws, and reports no error either. - assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU)], Some(NVIDIA)), Some(0)); - assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)], Some(NVIDIA)), Some(0)); - // The usual hybrid laptop, with the compositor on the integrated GPU: as before (#8). - assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)], Some(AMD_IGPU)), Some(1)); - // No GPU drives a display as far as sysfs says (a container), but the compositor knows. - assert_eq!(super::pick_adapter(&adapters(), &[], Some(NVIDIA)), Some(0)); - // A compositor GPU no adapter matches (a virtual one) leaves the displays in charge. - assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU)], Some((0x1af4, 0x1050))), Some(1)); - } - - #[test] - fn pick_adapter_falls_back_to_low_power_without_a_match() { - assert_eq!(super::pick_adapter(&adapters(), &[monitor((0x8086, 0x1234))], None), Some(1)); - assert_eq!(super::pick_adapter(&adapters(), &[monitor((0x8086, 0x1234)), monitor((0x8086, 0x5678))], None), Some(1)); - assert_eq!(super::pick_adapter(&[], &[monitor(NVIDIA)], None), None); - } - - #[test] - fn internal_panels_are_edp_lvds_and_dsi_connectors() { - for name in ["eDP-1", "LVDS-1", "DSI-1"] { - assert!(super::is_internal_panel(name), "{name}"); - } - for name in ["DP-3", "HDMI-A-1", "DVI-D-1", "VGA-1", "Writeback-1", ""] { - assert!(!super::is_internal_panel(name), "{name}"); - } - } - - #[cfg(target_os = "linux")] - #[test] - fn linux_display_gpus_reads_connected_connectors() -> std::io::Result<()> { - let dir = std::env::temp_dir().join(format!("pdfcraft-drm-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - let card = |name: &str, (vendor, device): (u32, u32)| -> std::io::Result<()> { - std::fs::create_dir_all(dir.join(name).join("device"))?; - std::fs::write(dir.join(name).join("device/vendor"), format!("{vendor:#06x}\n"))?; - std::fs::write(dir.join(name).join("device/device"), format!("{device:#06x}\n")) - }; - let connector = |name: &str, status: &str| -> std::io::Result<()> { - std::fs::create_dir_all(dir.join(name))?; - std::fs::write(dir.join(name).join("status"), format!("{status}\n")) - }; - card("card1", NVIDIA)?; - card("card2", AMD_IGPU)?; - connector("card1-DP-3", "connected")?; - connector("card1-DP-4", "connected")?; - connector("card2-HDMI-A-1", "disconnected")?; - connector("card2-Writeback-1", "unknown")?; - let desktop = super::linux_display_gpus(&dir); - // Issue #445: a monitor on the integrated GPU too, still no built-in panel. - connector("card2-HDMI-A-1", "connected")?; - let mut both = super::linux_display_gpus(&dir); - // A hybrid laptop: the integrated GPU drives the built-in panel as well. - connector("card2-eDP-1", "connected")?; - let mut laptop = super::linux_display_gpus(&dir); - std::fs::remove_dir_all(&dir)?; - assert_eq!(desktop, vec![monitor(NVIDIA)]); - both.sort_by_key(|g| g.pci); - assert_eq!(both, vec![monitor(AMD_IGPU), monitor(NVIDIA)]); - laptop.sort_by_key(|g| g.pci); - assert_eq!(laptop, vec![panel(AMD_IGPU), monitor(NVIDIA)]); - assert!(super::linux_display_gpus(std::path::Path::new("/nonexistent/drm")).is_empty()); - Ok(()) - } - - /// A fresh folder per call: tests run in parallel. - fn scratch() -> std::path::PathBuf { - static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); - let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - let d = std::env::temp_dir().join(format!("pdfcraft-control-file-{}-{n}", std::process::id())); - let _ = std::fs::remove_dir_all(&d); - std::fs::create_dir_all(&d).unwrap(); - d - } - - /// The control file's port and token, and every name in its folder. - fn written(dir: &std::path::Path) -> (serde_json::Value, Vec) { - let json = serde_json::from_str(&std::fs::read_to_string(dir.join("ctl.json")).unwrap()).unwrap(); - let mut names: Vec = std::fs::read_dir(dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect(); - names.sort(); - (json, names) - } - - #[test] - fn the_control_file_replaces_a_hard_link_instead_of_writing_through_it() { - // The file used to be opened with create + truncate, so a link planted at the path had its - // target truncated and overwritten with the token. - let dir = scratch(); - std::fs::write(dir.join("victim.txt"), "keep me").unwrap(); - std::fs::hard_link(dir.join("victim.txt"), dir.join("ctl.json")).unwrap(); - super::write_control_file(&dir.join("ctl.json").to_string_lossy(), 4242, "t0ken").unwrap(); - assert_eq!(std::fs::read_to_string(dir.join("victim.txt")).unwrap(), "keep me"); - let (json, names) = written(&dir); - assert_eq!((json["port"].as_u64(), json["token"].as_str()), (Some(4242), Some("t0ken"))); - assert_eq!(names, ["ctl.json", "victim.txt"], "no temporary file left behind"); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn the_control_file_replaces_a_symlink_instead_of_writing_through_it() { - let dir = scratch(); - let (link, target) = (dir.join("ctl.json"), dir.join("victim.txt")); - std::fs::write(&target, "keep me").unwrap(); - #[cfg(unix)] - let made = std::os::unix::fs::symlink(&target, &link); - #[cfg(windows)] - let made = std::os::windows::fs::symlink_file(&target, &link); - if let Err(e) = made { - // Windows needs Developer Mode (or admin) for symlinks. - eprintln!("skipped: can't create a symlink here: {e}"); - let _ = std::fs::remove_dir_all(&dir); - return; - } - super::write_control_file(&link.to_string_lossy(), 4242, "t0ken").unwrap(); - assert_eq!(std::fs::read_to_string(&target).unwrap(), "keep me"); - assert!(!std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink()); - assert_eq!(written(&dir).0["token"].as_str(), Some("t0ken")); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn the_control_file_replaces_the_one_a_previous_run_left() { - let dir = scratch(); - std::fs::write(dir.join("ctl.json"), r#"{"port":1,"token":"old"}"#).unwrap(); - super::write_control_file(&dir.join("ctl.json").to_string_lossy(), 4242, "t0ken").unwrap(); - let (json, names) = written(&dir); - assert_eq!(json["token"].as_str(), Some("t0ken")); - assert_eq!(names, ["ctl.json"]); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mode = std::fs::metadata(dir.join("ctl.json")).unwrap().permissions().mode(); - assert_eq!(mode & 0o077, 0, "only the owner can read the token: {mode:o}"); - } - let _ = std::fs::remove_dir_all(&dir); - } - - /// X11 startup notification and docks match a window to its launcher by `StartupWMClass`, - /// so it has to be the class the window carries: the app id, on Wayland and X11 alike. - #[test] - fn the_desktop_entry_names_the_window_class() { - let entry = include_str!("../../../packaging/linux/ai.storyteller.pdfcraft.desktop"); - let class = entry.lines().find_map(|line| line.strip_prefix("StartupWMClass=")); - assert_eq!(class, Some(super::APP_ID)); - } - - // Regression test for #246: `eprintln!` panics when standard error is a broken pipe (a GUI - // launch on Windows), and a panic inside the panic hook aborts the process. The start-up - // path and the hook must write to standard error without it. - #[test] - fn start_up_and_the_panic_hook_never_use_eprintln() { - let source = include_str!("main.rs"); - // Everything before the top-level test module is product code. - let product = source.split("\n#[cfg(test)]\nmod tests {").next().unwrap_or(source); - assert!(product.len() < source.len(), "found the end of the product code"); - let offenders: Vec<_> = product - .lines() - .enumerate() - .filter(|(_, line)| !line.trim_start().starts_with("//") && line.contains("eprintln!")) - .map(|(n, line)| format!("main.rs:{}: {}", n + 1, line.trim())) - .collect(); - assert!( - offenders.is_empty(), - "`eprintln!` panics on a broken standard error pipe; use `log::warn!` or `write_fmt` on `stderr()` and ignore the error:\n{}", - offenders.join("\n") - ); - } -} +mod tests; diff --git a/apps/pdfcraft/src/startup.rs b/apps/pdfcraft/src/startup.rs new file mode 100644 index 0000000000..be25da45b6 --- /dev/null +++ b/apps/pdfcraft/src/startup.rs @@ -0,0 +1,244 @@ +//! Native launch arguments and per-file view overrides (#1354). +use pdfcraft_ui_egui::PdfCraftApp; + +#[derive(Default)] +pub(super) struct Arguments { + pub files: Vec, + pub options: Vec<(String, String)>, + pub control_file: Option, + pub create_images: bool, + pub new_window: bool, + pub two_paged: bool, + pub version: bool, + pub help: bool, +} + +impl Arguments { + pub fn may_hand_off(&self) -> bool { + !self.new_window && !self.files.is_empty() && self.options.is_empty() && self.control_file.is_none() && !self.create_images && !self.two_paged + } + + pub fn parse(args: impl IntoIterator) -> Self { + let mut parsed = Self::default(); + let mut args = args.into_iter(); + while let Some(a) = args.next() { + match a.as_str() { + "--version" => { + parsed.version = true; + break; + } + "-h" | "--help" => { + parsed.help = true; + break; + } + "--control" => parsed.control_file = args.next(), + "--create-images" => parsed.create_images = true, + "--new-window" => parsed.new_window = true, + "--two-paged" => parsed.two_paged = true, + flag if flag.starts_with("--two-paged=") => { + // It has no value: do not consume the following filename on malformed use. + log::warn!("{flag}: use --two-paged without a value"); + } + flag if flag.starts_with("--") => { + let value = args.next().unwrap_or_default(); + parsed.options.push((flag.trim_start_matches("--").to_string(), value)); + } + _ => parsed.files.push(super::path_from_arg(a)), + } + } + parsed + } +} + +pub(super) fn open_files(app: &mut PdfCraftApp, files: &[String], two_paged: bool) { + for path in files { + if two_paged { + // The per-file override wins over initial view without changing saved defaults. + app.open_path_with_display(path, pdfcraft_ui_egui::canvas::PageLayout::TwoUp, false); + } else { + app.open_path(path); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(args: &[&str]) -> Arguments { + Arguments::parse(args.iter().map(|s| s.to_string())) + } + + #[test] + fn standalone_flag_keeps_files_in_either_position() { + for args in + [["--two-paged", "first.pdf", "second.pdf"], ["first.pdf", "--two-paged", "second.pdf"], ["first.pdf", "second.pdf", "--two-paged"]] + { + let parsed = parse(&args); + assert!(parsed.two_paged); + assert_eq!(parsed.files, ["first.pdf", "second.pdf"]); + assert!(parsed.options.is_empty()); + assert!(!parsed.may_hand_off()); + } + let empty = parse(&["--two-paged"]); + assert!(empty.two_paged && empty.files.is_empty()); + let normal = parse(&["first.pdf", "second.pdf"]); + assert!(!normal.two_paged); + assert!(normal.may_hand_off()); + for args in [ + &["--new-window", "first.pdf"][..], + &["--layout", "two-page", "first.pdf"], + &["--control", "control.json", "first.pdf"], + &["--create-images", "first.png"], + ] { + assert!(!parse(args).may_hand_off()); + } + } + + #[test] + fn existing_layout_options_are_preserved_in_either_order() { + for args in [["--two-paged", "--layout", "single", "file.pdf"], ["--layout", "single", "--two-paged", "file.pdf"]] { + let parsed = parse(&args); + assert!(parsed.two_paged); + assert_eq!(parsed.files, ["file.pdf"]); + assert_eq!(parsed.options, [("layout".into(), "single".into())]); + } + let parsed = parse(&["--two-paged", "--default-layout", "two-up", "file.pdf"]); + assert_eq!(parsed.options, [("default-layout".into(), "two-up".into())]); + } + + #[test] + fn newer_options_and_version_are_preserved() { + let parsed = parse(&["--two-paged", "--new-window", "--grid-zoom", "150", "--default-mode", "read", "file:///tmp/a%20b.pdf"]); + assert!(parsed.two_paged && parsed.new_window); + assert_eq!(parsed.files, ["/tmp/a b.pdf"]); + assert_eq!(parsed.options, [("grid-zoom".into(), "150".into()), ("default-mode".into(), "read".into())]); + assert!(!parsed.may_hand_off()); + let version = parse(&["--two-paged", "--version", "ignored.pdf"]); + assert!(version.version && version.files.is_empty()); + } + + #[test] + fn help_keeps_the_headless_early_exit() { + for flag in ["-h", "--help"] { + let args = parse(&["--two-paged", flag, "ignored.pdf"]); + assert!(args.help && args.files.is_empty()); + } + } + + #[test] + fn malformed_boolean_does_not_eat_a_file_or_option() { + let args = parse(&["--two-paged=false", "first.pdf", "--layout", "single", "second.pdf"]); + assert!(!args.two_paged); + assert_eq!(args.files, ["first.pdf", "second.pdf"]); + assert_eq!(args.options, [("layout".into(), "single".into())]); + } + + #[test] + fn startup_override_is_per_file_and_does_not_persist() { + use std::io::Write as _; + let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let path = std::env::temp_dir().join(format!("pdfcraft-two-paged-{}-{nonce}.pdf", std::process::id())); + let mut file = std::fs::OpenOptions::new().create_new(true).write(true).open(&path).unwrap(); + file.write_all(include_bytes!("../../../crates/ui-egui/tests/data/form.pdf")).unwrap(); + drop(file); + let second_path = path.with_extension("second.pdf"); + std::fs::copy(&path, &second_path).unwrap(); + let text = path.to_str().unwrap(); + let second = second_path.to_str().unwrap(); + for enabled in [false, true] { + let mut app = PdfCraftApp::new(); + app.set_option("default-layout", "single").unwrap(); + app.open_bytes("restored.pdf", None, include_bytes!("../../../crates/ui-egui/tests/data/form.pdf").to_vec()).unwrap(); + let restored = app.views[0].id; + open_files(&mut app, &[text.into(), second.into()], enabled); + assert_eq!(app.views.len(), 3); + assert_eq!(app.views[0].id, restored); + assert_eq!(app.views[0].layout, pdfcraft_ui_egui::canvas::PageLayout::Single); + for view in app.views.iter().skip(1) { + assert_eq!( + view.layout, + if enabled { pdfcraft_ui_egui::canvas::PageLayout::TwoUp } else { pdfcraft_ui_egui::canvas::PageLayout::Single } + ); + assert!(!view.continuous()); + } + let persisted: serde_json::Value = serde_json::from_str(&app.persist()).unwrap(); + assert_eq!(persisted["default_layout"], "single"); + assert!(persisted.get("two_paged").is_none()); + // Existing --layout applies after the flag to the active document only. + if enabled { + app.set_launch_layout("continuous").unwrap(); + } else { + app.set_option("layout", "continuous").unwrap(); + } + assert_eq!(app.views[2].layout, pdfcraft_ui_egui::canvas::PageLayout::Single); + assert!(app.views[2].continuous()); + app.set_option("default-layout", "two-up").unwrap(); + assert_eq!(app.view_defaults.layout, pdfcraft_ui_egui::canvas::PageLayout::TwoUp); + assert!(app.view_defaults.continuous); + open_files(&mut app, &[], enabled); + assert_eq!(app.views.len(), 3); + } + std::fs::remove_file(path).unwrap(); + std::fs::remove_file(second_path).unwrap(); + } + + #[test] + fn explicit_launch_layout_preserves_argument_order_and_active_file() { + use pdfcraft_ui_egui::canvas::PageLayout; + let path = std::env::temp_dir().join(format!("pdfcraft-explicit-layout-{}.pdf", std::process::id())); + std::fs::write(&path, include_bytes!("../../../crates/ui-egui/tests/data/form.pdf")).unwrap(); + let second_path = path.with_extension("second.pdf"); + std::fs::copy(&path, &second_path).unwrap(); + let names = [path.to_str().unwrap(), second_path.to_str().unwrap()]; + for prefix in [["--two-paged", "--layout", "single"], ["--layout", "single", "--two-paged"]] { + for count in [1, 2] { + let args = Arguments::parse(prefix.into_iter().chain(names.iter().take(count).copied()).map(str::to_owned)); + let mut app = PdfCraftApp::new(); + app.set_option("default-layout", "continuous").unwrap(); + app.open_bytes("restored.pdf", None, include_bytes!("../../../crates/ui-egui/tests/data/form.pdf").to_vec()).unwrap(); + open_files(&mut app, &args.files, args.two_paged); + for (key, value) in args.options { + assert_eq!(key, "layout"); + app.set_launch_layout(&value).unwrap(); + } + assert_eq!(app.active, Some(count)); + assert!(app.views[0].continuous()); + for view in app.views.iter().skip(1).take(count - 1) { + assert_eq!(view.layout, PageLayout::TwoUp); + } + assert_eq!(app.views[count].layout, PageLayout::Single); + assert!(!app.views[count].continuous()); + assert!(app.view_defaults.continuous); + } + } + std::fs::remove_file(path).unwrap(); + std::fs::remove_file(second_path).unwrap(); + } +} + +#[cfg(test)] +mod repeated_file_tests { + use super::*; + + #[test] + fn a_repeated_file_reuses_its_tab_and_receives_the_launch_override() { + use pdfcraft_ui_egui::canvas::PageLayout; + let path = std::env::temp_dir().join(format!("pdfcraft-repeated-startup-{}.pdf", std::process::id())); + std::fs::write(&path, include_bytes!("../../../crates/ui-egui/tests/data/form.pdf")).unwrap(); + let name = path.to_str().unwrap().to_string(); + let mut app = PdfCraftApp::new(); + app.set_option("default-layout", "continuous").unwrap(); + app.open_path(&name); + let original = app.views[0].id; + open_files(&mut app, &[name.clone(), name], true); + assert_eq!(app.views.len(), 1); + assert_eq!(app.views[0].id, original); + assert_eq!(app.views[0].layout, PageLayout::TwoUp); + assert!(!app.views[0].continuous()); + app.set_launch_layout("single").unwrap(); + assert_eq!(app.views[0].layout, PageLayout::Single); + assert!(app.view_defaults.continuous); + std::fs::remove_file(path).unwrap(); + } +} diff --git a/apps/pdfcraft/src/tests.rs b/apps/pdfcraft/src/tests.rs new file mode 100644 index 0000000000..c6e6f36518 --- /dev/null +++ b/apps/pdfcraft/src/tests.rs @@ -0,0 +1,686 @@ +/// The lockfile package block for `name`, or a panic naming the missing package. +fn locked_package(root: &std::path::Path, name: &str) -> String { + let lock = std::fs::read_to_string(root.join("Cargo.lock")).expect("Cargo.lock"); + lock.split("[[package]]") + .find(|p| p.contains(&format!("\nname = \"{name}\"\n"))) + .map(str::to_owned) + .unwrap_or_else(|| panic!("{name} is not in Cargo.lock")) +} + +/// Interim graph check for the window stack while the reviewed fork revision is not yet +/// published. The old `vendor/winit` / `vendor/egui-wgpu` path patches were removed from the +/// published manifest; local integration supplies the reviewed worktrees through an external +/// Cargo config patch. A git source must name the public storytold fork; a path source is the +/// local integration graph. Behaviour (surface fitting, error scopes, scroll phases) is tested +/// directly elsewhere; the retained vendor copies keep provenance until the fork revision is +/// pinned. No public revision is invented here. +fn assert_window_stack_graph(root: &std::path::Path, name: &str, version: &str) -> String { + let package = locked_package(root, name); + assert!(package.contains(&format!("\nversion = \"{version}\"\n")), "{name} must resolve to {version}: {package}"); + if let Some(source) = package.lines().find_map(|line| line.strip_prefix("source = ")) { + assert!(source.contains("github.com/storytold/"), "{name} must come from the reviewed storytold fork once published, not {source}"); + assert!( + source.split(|c: char| !c.is_ascii_hexdigit()).any(|part| part.len() == 40), + "{name} must pin the fork's 40-character revision, not {source}" + ); + } + // A path patch has no `source =` in Cargo.lock. The local integration config is the only + // allowed path source; a machine-local path must never be in the published manifest, + // which the root-manifest assertion below checks. A path entry carries no remote identity + // to verify, so this test does not claim the resolved code is the published fork. + let manifest = std::fs::read_to_string(root.join("Cargo.toml")).expect("root Cargo.toml"); + assert!(!manifest.contains(&format!("{name} = {{ path = \"vendor/")), "vendor/{name} must not be a published path patch"); + package +} + +use pdfcraft_ui_egui::control::Endpoint; + +fn endpoint() -> std::io::Result { + Ok(Endpoint { port: 4321, token: "0123456789abcdef".into() }) +} + +/// Tests that open a real GPU device take this first, so only one of them is inside the +/// graphics libraries at a time. The test threads otherwise run them together, and on +/// FreeBSD's CI machine the test process then died with SIGSEGV, in about half the runs: +/// the first device was still being set up when the second test began its own. The UI +/// tests take turns the same way (`crates/ui-egui/tests/it/main.rs`). Hold it for the whole +/// test (declared before the instance, so it's released after the device is dropped). +static GPU: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +fn gpu() -> std::sync::MutexGuard<'static, ()> { + // A test that panicked while holding it leaves nothing to clean up. + GPU.lock().unwrap_or_else(std::sync::PoisonError::into_inner) +} + +#[test] +fn a_control_channel_that_cant_be_published_stops_the_app() { + let dir = std::env::temp_dir().join(format!("pdfcraft-app-control-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = |name: &str| dir.join(name).to_str().unwrap().to_owned(); + + // A folder in the way stands in for a file another user created first: the write fails. + std::fs::create_dir(dir.join("taken.json")).unwrap(); + let err = super::open_control_channel(&path("taken.json"), endpoint()).unwrap_err(); + assert!(err.contains("--control") && err.contains("taken.json") && err.contains("only you can write"), "{err}"); + // No listener: nothing to publish. + let err = super::open_control_channel(&path("pc.json"), Err(std::io::Error::other("no loopback"))).unwrap_err(); + assert!(err.contains("no loopback"), "{err}"); + assert!(!dir.join("pc.json").exists()); + + super::open_control_channel(&path("pc.json"), endpoint()).unwrap(); + let written: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(dir.join("pc.json")).unwrap()).unwrap(); + assert_eq!((written["port"].as_u64(), written["token"].as_str()), (Some(4321), Some("0123456789abcdef"))); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn path_from_arg_decodes_file_uris() { + assert_eq!(super::path_from_arg("file:///home/alice/report.pdf".to_string()), "/home/alice/report.pdf"); + assert_eq!(super::path_from_arg("file:///home/alice/my%20report.pdf".to_string()), "/home/alice/my report.pdf"); + assert_eq!(super::path_from_arg("file://localhost/tmp/a%C3%A9.pdf".to_string()), "/tmp/aé.pdf"); + // Malformed or truncated escapes are kept as written; bytes that aren't UTF-8 keep the URI. + assert_eq!(super::path_from_arg("file:///tmp/100%.pdf".to_string()), "/tmp/100%.pdf"); + assert_eq!(super::path_from_arg("file:///tmp/a%2".to_string()), "/tmp/a%2"); + assert_eq!(super::path_from_arg("file:///tmp/%zz%".to_string()), "/tmp/%zz%"); + assert_eq!(super::path_from_arg("file:///tmp/%FF.pdf".to_string()), "file:///tmp/%FF.pdf"); +} + +#[test] +fn unquoted_paths_with_spaces_are_rejoined() { + let have = ["P:\\Archive\\00042A26 Contract.PDF", "C:\\a b c.pdf", "C:\\a.pdf", "C:\\x y.pdf"]; + let exists = |p: &str| have.contains(&p); + let args = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::>(); + // The case an office program produced: one path, split at its space. + assert_eq!(super::rejoin_split_paths(args(&["P:\\Archive\\00042A26", "Contract.PDF"]), exists), args(&["P:\\Archive\\00042A26 Contract.PDF"])); + // The longest run wins; files that exist on their own are left alone. + assert_eq!(super::rejoin_split_paths(args(&["C:\\a", "b", "c.pdf", "C:\\a.pdf"]), exists), args(&["C:\\a b c.pdf", "C:\\a.pdf"])); + assert_eq!(super::rejoin_split_paths(args(&["C:\\a.pdf", "C:\\x", "y.pdf"]), exists), args(&["C:\\a.pdf", "C:\\x y.pdf"])); + // Nothing to join: kept as given, so the missing file is still reported by name. + assert_eq!(super::rejoin_split_paths(args(&["missing.pdf", "other"]), exists), args(&["missing.pdf", "other"])); + assert!(super::rejoin_split_paths(Vec::new(), exists).is_empty()); +} + +#[test] +fn path_from_arg_leaves_plain_paths_and_other_schemes_alone() { + assert_eq!(super::path_from_arg("report.pdf".to_string()), "report.pdf"); + assert_eq!(super::path_from_arg("/home/alice/report.pdf".to_string()), "/home/alice/report.pdf"); + assert_eq!(super::path_from_arg("https://example.com/report.pdf".to_string()), "https://example.com/report.pdf"); + assert_eq!(super::path_from_arg("file://server/share/a.pdf".to_string()), "file://server/share/a.pdf"); + assert_eq!(super::path_from_arg("file://".to_string()), "file://"); +} + +#[test] +fn opengl_is_the_fallback_unless_a_renderer_was_chosen() { + use super::{RendererChoice, native_options, renderer_choice, retry_with_gl}; + assert_eq!(renderer_choice(None), RendererChoice::Auto); + assert_eq!(renderer_choice(Some("")), RendererChoice::Auto); + assert_eq!(renderer_choice(Some("vulkan")), RendererChoice::Auto); + assert_eq!(renderer_choice(Some(" WGPU ")), RendererChoice::Wgpu); + for gl in ["gl", "OpenGL", "glow"] { + assert_eq!(renderer_choice(Some(gl)), RendererChoice::Gl); + } + // Retried with OpenGL only when wgpu failed to start by default, never after the app ran. + assert!(retry_with_gl(RendererChoice::Auto, false)); + assert!(!retry_with_gl(RendererChoice::Auto, true)); + assert!(!retry_with_gl(RendererChoice::Wgpu, false)); + assert!(!retry_with_gl(RendererChoice::Gl, false)); + // Each run states its renderer rather than relying on eframe's default. + assert_eq!(native_options(false, false, eframe::Renderer::Wgpu).renderer, eframe::Renderer::Wgpu); + assert_eq!(native_options(false, false, eframe::Renderer::Glow).renderer, eframe::Renderer::Glow); +} + +#[test] +fn the_desktop_frame_is_turned_off_only_when_the_app_draws_one() { + use super::native_options; + assert_eq!(native_options(false, false, eframe::Renderer::Glow).viewport.decorations, None); + assert_eq!(native_options(false, true, eframe::Renderer::Glow).viewport.decorations, Some(false)); +} + +#[test] +fn a_software_renderer_is_shown_to_the_user() { + use eframe::wgpu::DeviceType; + // Issue #519: WARP or llvmpipe is announced in the app, not just in pdfcraft.log. + let mut app = super::PdfCraftApp::new(); + super::notify_software_renderer(&mut app, DeviceType::Cpu); + let notice = app.toast.as_ref().map(|(m, _)| m.as_str()).unwrap_or_default(); + assert!(notice.contains("without a graphics processor"), "{notice:?}"); + for gpu in [DeviceType::IntegratedGpu, DeviceType::DiscreteGpu, DeviceType::VirtualGpu, DeviceType::Other] { + let mut app = super::PdfCraftApp::new(); + super::notify_software_renderer(&mut app, gpu); + assert!(app.toast.is_none(), "{gpu:?}: {:?}", app.toast); + } +} + +#[test] +fn gpu_setup_avoids_vulkan_and_stretched_resizes_on_windows_and_prefers_low_power() { + let mut native = eframe::NativeOptions::default(); + super::configure_gpu(&mut native); + let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &native.wgpu_options.wgpu_setup else { panic!("default setup creates its own instance") }; + if std::env::var_os("WGPU_POWER_PREF").is_none() { + assert_eq!(setup.power_preference, eframe::wgpu::PowerPreference::LowPower); + } + if std::env::var_os("WGPU_VALIDATION_INDIRECT_CALL").is_none() { + let flags = setup.instance_descriptor.flags; + assert!(!flags.contains(eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL), "Intel Metal can't compile its shader: {flags:?}"); + } + if cfg!(target_os = "windows") && std::env::var_os("WGPU_BACKEND").is_none() { + let backends = setup.instance_descriptor.backends; + assert!(backends.contains(eframe::wgpu::Backends::DX12), "{backends:?}"); + assert!(!backends.contains(eframe::wgpu::Backends::VULKAN), "issue #37: {backends:?}"); + } + use eframe::wgpu::Dx12SwapchainKind::{DxgiFromHwnd, DxgiFromVisual}; + assert_eq!(super::dx12_presentation(None), DxgiFromVisual); + assert_eq!(super::dx12_presentation(Some(DxgiFromHwnd)), DxgiFromHwnd, "WGPU_DX12_PRESENTATION_SYSTEM still decides"); + assert_eq!(super::dx12_presentation(Some(DxgiFromVisual)), DxgiFromVisual); + let presentation = setup.instance_descriptor.backend_options.dx12.presentation_system; + if cfg!(target_os = "windows") { + // A composition visual unless WGPU_DX12_PRESENTATION_SYSTEM asks otherwise. + let asked = eframe::wgpu::Dx12SwapchainKind::from_env(); + assert_eq!(presentation, asked.unwrap_or(DxgiFromVisual), "issue #2738: resizing stretches a window swap chain"); + } else { + assert_eq!(presentation, eframe::wgpu::Dx12SwapchainKind::default().with_env(), "only Windows changes it"); + } +} + +#[test] +fn restored_hidpi_window_fits_the_adapters_surface_limit() { + // The Outlook launch crash: 3440 x 1369 saved points at 250% DPI requested this surface. + let (width, height) = (8600, 3423); + let required = eframe::wgpu::Limits::default(); + assert!(width > required.max_texture_dimension_2d); + let supported = eframe::wgpu::Limits { max_texture_dimension_2d: 16384, ..required.clone() }; + let enabled = super::surface_texture_limits(required, &supported); + assert!(width <= enabled.max_texture_dimension_2d); + assert!(height <= enabled.max_texture_dimension_2d); +} + +#[test] +fn surface_limits_preserve_other_requirements_and_never_overrequest() { + for required in [eframe::wgpu::Limits::default(), eframe::wgpu::Limits::downlevel_webgl2_defaults()] { + for extent in [4096, 8192, 16384] { + let supported = eframe::wgpu::Limits { + max_texture_dimension_2d: extent, + max_bind_groups: 8, + min_uniform_buffer_offset_alignment: 128, + min_storage_buffer_offset_alignment: 128, + ..required.clone() + }; + let enabled = super::surface_texture_limits(required.clone(), &supported); + let mut expected = required.clone(); + expected.max_texture_dimension_2d = extent; + assert_eq!(enabled, expected); + assert_eq!(enabled.max_texture_dimension_2d, supported.max_texture_dimension_2d); + assert!(enabled.check_limits(&supported)); + } + } +} + +#[test] +fn surface_limits_fit_v3d_shader_variable_limit() { + // Issue #2383: V3D supports 15 variables, below the default request of 16. + let required = eframe::wgpu::Limits::default(); + let supported = eframe::wgpu::Limits { max_texture_dimension_2d: 7680, max_inter_stage_shader_variables: 15, ..required.clone() }; + let enabled = super::surface_texture_limits(required.clone(), &supported); + assert!(enabled.check_limits(&supported), "unsupported device limits: {enabled:?}"); + let expected = eframe::wgpu::Limits { max_texture_dimension_2d: 7680, max_inter_stage_shader_variables: 15, ..required }; + assert_eq!(enabled, expected); +} + +#[test] +fn surface_limits_fit_downlevel_maxima_and_minimum_alignments() { + let required = eframe::wgpu::Limits::downlevel_webgl2_defaults(); + let supported = eframe::wgpu::Limits { + max_texture_dimension_2d: 2048, + max_bind_groups: 2, + max_vertex_attributes: 8, + min_uniform_buffer_offset_alignment: 512, + min_storage_buffer_offset_alignment: 512, + ..required.clone() + }; + let enabled = super::surface_texture_limits(required, &supported); + assert!(enabled.check_limits(&supported), "unsupported device limits: {enabled:?}"); + // Larger minimum alignments are weaker capabilities, unlike maximum limits. + assert_eq!(enabled, supported); +} + +#[test] +fn a_window_larger_than_the_gpu_limit_gets_a_surface_within_it() { + use eframe::egui_wgpu::winit::surface_fit; + // Issue #577: 3440 x 1369 points restored at 250% asked an 8192-pixel device for this. + let (width, height, scale) = surface_fit(8600, 3423, 8192); + assert_eq!((width, height), (8192, 3260)); + // Both sides shrink alike and egui draws at that factor: the whole window is drawn. + assert!((8600.0 * scale - 8192.0).abs() < 0.01, "{scale}"); + assert!((3423.0 * scale - height as f32).abs() < 1.0, "{scale}"); + // Within the limit nothing changes, up to and including the limit itself. + assert_eq!(surface_fit(8600, 3423, 16384), (8600, 3423, 1.0)); + assert_eq!(surface_fit(8192, 8192, 8192), (8192, 8192, 1.0)); + assert_eq!(surface_fit(0, 0, 8192), (0, 0, 1.0)); + // One side over the limit: the window as it would be stretched across monitors. + assert_eq!(surface_fit(8193, 600, 8192).0, 8192); + assert_eq!(surface_fit(600, 8193, 8192).1, 8192); +} + +#[test] +fn surface_fit_never_empties_or_overflows_a_surface() { + use eframe::egui_wgpu::winit::surface_fit; + for (w, h, max) in + [(1, u32::MAX, 8192), (u32::MAX, 1, 2048), (u32::MAX, u32::MAX, 16384), (0, u32::MAX, 8192), (u32::MAX, 0, 8192), (5, 7, 0), (40_000, 3, 1)] + { + let (fw, fh, scale) = surface_fit(w, h, max); + assert!(fw <= max.max(1) && fh <= max.max(1), "{w} x {h} in {max}: {fw} x {fh}"); + // A side that wasn't zero stays non-zero (`Surface::configure` rejects an empty one), + // and a zero side stays zero (egui-wgpu skips configuring it). + assert_eq!((fw == 0, fh == 0), (w == 0, h == 0), "{w} x {h} in {max}: {fw} x {fh}"); + assert!(scale.is_finite() && scale > 0.0 && scale <= 1.0, "{w} x {h} in {max}: {scale}"); + } + // The longer side lands exactly on the limit (rounding never leaves it a pixel short), + // and the shorter side keeps the window's proportions to within a pixel. + for max in [2048, 8192, 16384] { + for long in (max + 1..=max * 5).step_by(997).chain([max * 2, max * 4, u32::MAX]) { + for short in [1, 3, 600, max / 3, max - 1, max, long - 1, long] { + let (fw, fh, scale) = surface_fit(long, short, max); + assert_eq!(fw, max, "{long} x {short} in {max}"); + let expected = f64::from(short) * f64::from(max) / f64::from(long); + assert!((f64::from(fh) - expected).abs() <= 1.0, "{long} x {short} in {max}: {fh}"); + assert_eq!(surface_fit(short, long, max), (fh, fw, scale), "transposed"); + } + } + } +} + +/// Regression test: the lockfile resolves egui-wgpu 0.37.0 through an allowed source, the root manifest +/// does not path-patch the vendor copy, and that copy stays as provenance for the surface-fit patch. +#[test] +fn vendor_provenance_egui_wgpu_surface_fit_identity() { + // Issue #577: egui-wgpu configured a window's surface at the window's size, and + // `Surface::configure` panics when that's beyond the device's `max_texture_dimension_2d` + // (emilk/egui#8361). The reviewed egui 0.37 worktree carries E2 (`surface_fit`); the + // behavioural test above exercises it. The old 0.36 vendor copy is retained for + // provenance only and is no longer a published path patch, so reading it would not prove + // what the app runs. + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + assert_window_stack_graph(&root, "egui-wgpu", "0.37.0"); + assert!(root.join("vendor/egui-wgpu/src/winit.rs").exists(), "the vendor copy and its provenance stay until the fork revision is pinned"); +} + +#[test] +fn a_gpu_error_while_a_window_is_set_up_is_returned_not_a_panic() { + // Issue #519: on a 2015 Intel GPU, wgpu's GL backend couldn't configure the window's + // surface (`GpuWaitTimeout`), and wgpu's default error handler panicked before the app was + // created, so PdfCraft never got to retry with OpenGL. vendor/egui-wgpu configures a new + // window's surface inside `catch_errors`. A real device on PdfCraft's own GPU settings, + // and an error wgpu raises before anything reaches the driver: a texture one pixel wider + // than the device allows. + use eframe::wgpu; + let _gpu = gpu(); + let native = super::native_options(false, false, eframe::Renderer::Wgpu); + let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &native.wgpu_options.wgpu_setup else { panic!("default setup creates its own instance") }; + let instance = pollster::block_on(native.wgpu_options.wgpu_setup.new_instance()); + let options = wgpu::RequestAdapterOptions { power_preference: setup.power_preference, ..Default::default() }; + // CI runners have a software adapter (WARP, llvmpipe); a machine without any skips. + let Ok(adapter) = pollster::block_on(instance.request_adapter(&options)) else { + eprintln!("skipping: no GPU adapter on this machine"); + return; + }; + let Ok((device, _queue)) = pollster::block_on(adapter.request_device(&(setup.device_descriptor)(&adapter))) else { + eprintln!("skipping: the adapter gives no device"); + return; + }; + let texture = |width: u32| { + device.create_texture(&wgpu::TextureDescriptor { + label: Some("issue-519"), + size: wgpu::Extent3d { width, height: 1, depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }) + }; + let too_wide = device.limits().max_texture_dimension_2d.saturating_add(1); + let caught = pollster::block_on(eframe::egui_wgpu::winit::catch_errors(&device, || texture(too_wide))); + assert!(matches!(caught, Err(wgpu::Error::Validation { .. })), "{caught:?}"); + // The device stays usable, and nothing is left behind to catch later errors by mistake. + let fine = pollster::block_on(eframe::egui_wgpu::winit::catch_errors(&device, || texture(16))); + assert!(fine.is_ok(), "{fine:?}"); + assert_eq!(pollster::block_on(eframe::egui_wgpu::winit::catch_errors(&device, || 7)).ok(), Some(7)); + // Nested: the inner call keeps its own error, and the outer one still catches what follows. + let caught = pollster::block_on(eframe::egui_wgpu::winit::catch_errors(&device, || { + let inner = pollster::block_on(eframe::egui_wgpu::winit::catch_errors(&device, || texture(too_wide))); + assert!(inner.is_err(), "inner: {inner:?}"); + texture(too_wide) + })); + assert!(matches!(caught, Err(wgpu::Error::Validation { .. })), "outer: {caught:?}"); +} + +/// Regression test: the lockfile resolves egui-wgpu 0.37.0 through an allowed source, the root manifest +/// does not path-patch the vendor copy, and that copy stays as provenance for the catch-errors patch. +#[test] +fn vendor_provenance_egui_wgpu_catch_errors_identity() { + // Issue #519: E3 (`catch_errors`) is tested above against a real device. The reviewed 0.37 + // worktree carries it around the first configure of every new window's surface, and its + // error reaches eframe as `WgpuError` (eframe then returns `Error::Wgpu`, which `main` + // retries with OpenGL). Without it a failed configure panics before the app is created. + // `catch_errors` is exercised against a real device in the test above and below; the + // graph check makes sure the app runs the reviewed 0.37 worktree (the retained vendor + // copy is not read as proof of behaviour). + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + assert_window_stack_graph(&root, "egui-wgpu", "0.37.0"); + assert!(root.join("vendor/egui-wgpu/src/winit.rs").exists(), "the vendor copy and its provenance stay until the fork revision is pinned"); +} + +#[test] +fn pipeline_creation_error_is_returned_and_device_remains_usable() { + use eframe::{egui_wgpu, wgpu}; + let _gpu = gpu(); + let mut native = super::native_options(false, false, eframe::Renderer::Wgpu); + let egui_wgpu::WgpuSetup::CreateNew(setup) = &native.wgpu_options.wgpu_setup else { panic!("default setup creates its own instance") }; + let instance = pollster::block_on(native.wgpu_options.wgpu_setup.new_instance()); + let options = wgpu::RequestAdapterOptions { power_preference: setup.power_preference, ..Default::default() }; + let Ok(adapter) = pollster::block_on(instance.request_adapter(&options)) else { + assert!(std::env::var_os("PDFCRAFT_TEST_REQUIRE_GPU").is_none(), "a GPU adapter is required for this run"); + eprintln!("skipping pipeline regression: no GPU adapter"); + return; + }; + let (device, queue) = + pollster::block_on(adapter.request_device(&(setup.device_descriptor)(&adapter))).expect("the selected adapter must provide a test device"); + native.wgpu_options.wgpu_setup = + egui_wgpu::WgpuSetup::Existing(egui_wgpu::WgpuSetupExisting { instance: instance.clone(), adapter, device, queue }); + // Three samples is invalid on every backend, so this reproduces a pipeline error + // without depending on a particular driver. The unpatched constructor panics. + for samples in [3, 1, 3, 1] { + let options = egui_wgpu::RendererOptions { msaa_samples: samples, ..Default::default() }; + let result = pollster::block_on(egui_wgpu::RenderState::create(&native.wgpu_options, &instance, None, options)); + if samples == 3 { + let error = result.err().expect("invalid pipeline must return an error"); + assert!(error.to_string().contains("egui_pipeline"), "{error}"); + } else { + assert!(result.is_ok(), "the device must remain usable: {:?}", result.err()); + } + } +} + +/// Regression test: winit resolves to 0.30.13 without a published vendor path patch, the retained copy +/// still carries the W1 Windows 11 monitor-scale fix, and its upstream base is recorded. +#[test] +fn winit_carries_the_windows_11_monitor_scale_fix() { + // Issue #324: winit 0.30.13 as released nudges a window dragged onto a monitor with another + // scale factor back onto the one it is leaving, so on Windows 11 it ends up on the wrong + // monitor, at the wrong size and scale. vendor/winit carries the fix from winit master. A + // dependency bump that resolves winit from crates.io again, or a re-vendored copy without + // the patch, would silently bring the bug back: re-apply the patch, or drop the copy once a + // winit 0.30 release has the fix (vendor/README.md). + // W1 (and W2–W13) were audited against the reviewed winit worktree: the vendor rows in + // `vendor/README.md` match the fork's PATCHES.md entries W1–W13. The vendor source is no + // longer a published path patch, so this checks the actual graph, not the old copy. + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + assert_window_stack_graph(&root, "winit", "0.30.13"); + let event_loop = std::fs::read_to_string(root.join("vendor/winit/src/platform_impl/windows/event_loop.rs")).unwrap(); + // The audited W1 hunk itself, not merely the file's presence: Windows 11 applies the + // rect Windows suggests, while Windows 10 keeps the old adjustment. + for marker in [ + "PdfCraft patch: from Windows 11 (build 22000) on, apply the rect Windows suggests", + "if !WIN10_BUILD_VERSION.is_some_and(|build| build < 22000) {", + "new_outer_rect = suggested_rect;", + ] { + assert!(event_loop.contains(marker), "vendor/winit lost the W1 monitor-scale patch: {marker}"); + } + let provenance = std::fs::read_to_string(root.join("vendor/winit/.cargo_vcs_info.json")).unwrap(); + assert!(provenance.contains("e9809ef54b18499bb4f2cac945719ecc2a61061b"), "vendor/winit keeps its upstream base provenance"); +} + +const NVIDIA: (u32, u32) = (0x10de, 0x2684); +const NVIDIA_3090: (u32, u32) = (0x10de, 0x2204); +const AMD_IGPU: (u32, u32) = (0x1002, 0x164e); + +fn adapters() -> Vec<(u32, u32, eframe::wgpu::DeviceType)> { + use eframe::wgpu::DeviceType; + vec![(NVIDIA.0, NVIDIA.1, DeviceType::DiscreteGpu), (AMD_IGPU.0, AMD_IGPU.1, DeviceType::IntegratedGpu), (0, 0, DeviceType::Cpu)] +} + +const INTEL_IGPU: (u32, u32) = (0x8086, 0xa780); + +fn monitor(pci: (u32, u32)) -> super::DisplayGpu { + super::DisplayGpu { pci, primary: false } +} + +fn panel(pci: (u32, u32)) -> super::DisplayGpu { + super::DisplayGpu { pci, primary: true } +} + +#[test] +fn pick_adapter_prefers_the_gpu_driving_the_monitors() { + // A desktop whose monitors are all on the discrete GPU: the integrated one shows black. + assert_eq!(super::pick_adapter(&adapters(), &[monitor(NVIDIA)], None), Some(0)); + // Issue #378: the same on Windows, where the Ryzen integrated GPU without a monitor crashed + // the display driver. Windows also says which display is the primary one. + assert_eq!(super::pick_adapter(&adapters(), &[panel(NVIDIA)], None), Some(0)); +} + +#[test] +fn pick_adapter_prefers_the_gpu_driving_the_primary_display() { + // A Windows desktop with a monitor on each GPU: the window opens on the primary display. + assert_eq!(super::pick_adapter(&adapters(), &[monitor(AMD_IGPU), panel(NVIDIA)], None), Some(0)); + assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)], None), Some(1)); +} + +#[test] +fn pci_ids_come_from_windows_device_ids() { + assert_eq!(super::pci_ids("PCI\\VEN_10DE&DEV_2204&SUBSYS_40421458&REV_A1"), Some(NVIDIA_3090)); + assert_eq!(super::pci_ids("PCI\\VEN_1002&DEV_164E&SUBSYS_88771043&REV_C1"), Some(AMD_IGPU)); + assert_eq!(super::pci_ids("pci\\ven_1002&dev_164e"), Some(AMD_IGPU)); + // Remote Desktop and other non-PCI display devices, and malformed ids. + assert_eq!(super::pci_ids("ROOT\\BasicDisplay\\0000"), None); + assert_eq!(super::pci_ids("PCI\\VEN_10DE&SUBSYS_40421458"), None); + assert_eq!(super::pci_ids("PCI\\VEN_10DE&DEV_ZZZZ"), None); + assert_eq!(super::pci_ids("VEN_&DEV_"), None); + assert_eq!(super::pci_ids(""), None); +} + +#[cfg(target_os = "windows")] +#[test] +fn windows_display_gpus_lists_pci_gpus_with_a_monitor() { + // Whatever this machine has: every entry is a PCI GPU, at most one drives the primary + // display, and no GPU is listed twice. (A headless CI runner may list none.) + let gpus = super::windows_display_gpus(); + assert!(gpus.iter().filter(|g| g.primary).count() <= 1, "{gpus:?}"); + for (i, g) in gpus.iter().enumerate() { + assert!(g.pci.0 != 0, "{gpus:?}"); + assert!(!gpus[..i].iter().any(|h| h.pci == g.pci), "{gpus:?}"); + } +} + +#[test] +fn pick_adapter_keeps_the_integrated_gpu_on_hybrid_laptops() { + // Issue #8: the panel is on the integrated GPU, an external monitor on the discrete one. + assert_eq!(super::pick_adapter(&adapters(), &[monitor(NVIDIA), panel(AMD_IGPU)], None), Some(1)); + assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)], None), Some(1)); + assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU)], None), Some(1)); + assert_eq!(super::pick_adapter(&adapters(), &[monitor(AMD_IGPU)], None), Some(1)); +} + +#[test] +fn pick_adapter_prefers_the_discrete_gpu_on_desktops_with_a_monitor_on_each() { + // Issue #445: one monitor on the Intel iGPU, one on the NVIDIA card, no built-in panel. + use eframe::wgpu::DeviceType; + let desktop = [(INTEL_IGPU.0, INTEL_IGPU.1, DeviceType::IntegratedGpu), (NVIDIA.0, NVIDIA.1, DeviceType::DiscreteGpu), (0, 0, DeviceType::Cpu)]; + assert_eq!(super::pick_adapter(&desktop, &[monitor(INTEL_IGPU), monitor(NVIDIA)], None), Some(1)); + assert_eq!(super::pick_adapter(&desktop, &[monitor(NVIDIA), monitor(INTEL_IGPU)], None), Some(1)); + // A GPU driving no display still loses to one that does. + assert_eq!(super::pick_adapter(&adapters(), &[monitor(AMD_IGPU), monitor(INTEL_IGPU)], None), Some(1)); +} + +#[test] +fn pick_adapter_prefers_the_gpu_the_compositor_draws_with() { + // A hybrid laptop whose Wayland compositor runs on the discrete GPU (Hyprland with + // `AQ_DRM_DEVICES` naming it first): the integrated GPU drives the built-in panel, yet the + // compositor shows nothing it draws, and reports no error either. + assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU)], Some(NVIDIA)), Some(0)); + assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)], Some(NVIDIA)), Some(0)); + // The usual hybrid laptop, with the compositor on the integrated GPU: as before (#8). + assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)], Some(AMD_IGPU)), Some(1)); + // No GPU drives a display as far as sysfs says (a container), but the compositor knows. + assert_eq!(super::pick_adapter(&adapters(), &[], Some(NVIDIA)), Some(0)); + // A compositor GPU no adapter matches (a virtual one) leaves the displays in charge. + assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU)], Some((0x1af4, 0x1050))), Some(1)); +} + +#[test] +fn pick_adapter_falls_back_to_low_power_without_a_match() { + assert_eq!(super::pick_adapter(&adapters(), &[monitor((0x8086, 0x1234))], None), Some(1)); + assert_eq!(super::pick_adapter(&adapters(), &[monitor((0x8086, 0x1234)), monitor((0x8086, 0x5678))], None), Some(1)); + assert_eq!(super::pick_adapter(&[], &[monitor(NVIDIA)], None), None); +} + +#[test] +fn internal_panels_are_edp_lvds_and_dsi_connectors() { + for name in ["eDP-1", "LVDS-1", "DSI-1"] { + assert!(super::is_internal_panel(name), "{name}"); + } + for name in ["DP-3", "HDMI-A-1", "DVI-D-1", "VGA-1", "Writeback-1", ""] { + assert!(!super::is_internal_panel(name), "{name}"); + } +} + +#[cfg(target_os = "linux")] +#[test] +fn linux_display_gpus_reads_connected_connectors() -> std::io::Result<()> { + let dir = std::env::temp_dir().join(format!("pdfcraft-drm-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let card = |name: &str, (vendor, device): (u32, u32)| -> std::io::Result<()> { + std::fs::create_dir_all(dir.join(name).join("device"))?; + std::fs::write(dir.join(name).join("device/vendor"), format!("{vendor:#06x}\n"))?; + std::fs::write(dir.join(name).join("device/device"), format!("{device:#06x}\n")) + }; + let connector = |name: &str, status: &str| -> std::io::Result<()> { + std::fs::create_dir_all(dir.join(name))?; + std::fs::write(dir.join(name).join("status"), format!("{status}\n")) + }; + card("card1", NVIDIA)?; + card("card2", AMD_IGPU)?; + connector("card1-DP-3", "connected")?; + connector("card1-DP-4", "connected")?; + connector("card2-HDMI-A-1", "disconnected")?; + connector("card2-Writeback-1", "unknown")?; + let desktop = super::linux_display_gpus(&dir); + // Issue #445: a monitor on the integrated GPU too, still no built-in panel. + connector("card2-HDMI-A-1", "connected")?; + let mut both = super::linux_display_gpus(&dir); + // A hybrid laptop: the integrated GPU drives the built-in panel as well. + connector("card2-eDP-1", "connected")?; + let mut laptop = super::linux_display_gpus(&dir); + std::fs::remove_dir_all(&dir)?; + assert_eq!(desktop, vec![monitor(NVIDIA)]); + both.sort_by_key(|g| g.pci); + assert_eq!(both, vec![monitor(AMD_IGPU), monitor(NVIDIA)]); + laptop.sort_by_key(|g| g.pci); + assert_eq!(laptop, vec![panel(AMD_IGPU), monitor(NVIDIA)]); + assert!(super::linux_display_gpus(std::path::Path::new("/nonexistent/drm")).is_empty()); + Ok(()) +} + +/// A fresh folder per call: tests run in parallel. +fn scratch() -> std::path::PathBuf { + static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); + let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let d = std::env::temp_dir().join(format!("pdfcraft-control-file-{}-{n}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +/// The control file's port and token, and every name in its folder. +fn written(dir: &std::path::Path) -> (serde_json::Value, Vec) { + let json = serde_json::from_str(&std::fs::read_to_string(dir.join("ctl.json")).unwrap()).unwrap(); + let mut names: Vec = std::fs::read_dir(dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect(); + names.sort(); + (json, names) +} + +#[test] +fn the_control_file_replaces_a_hard_link_instead_of_writing_through_it() { + // The file used to be opened with create + truncate, so a link planted at the path had its + // target truncated and overwritten with the token. + let dir = scratch(); + std::fs::write(dir.join("victim.txt"), "keep me").unwrap(); + std::fs::hard_link(dir.join("victim.txt"), dir.join("ctl.json")).unwrap(); + super::write_control_file(&dir.join("ctl.json").to_string_lossy(), 4242, "t0ken").unwrap(); + assert_eq!(std::fs::read_to_string(dir.join("victim.txt")).unwrap(), "keep me"); + let (json, names) = written(&dir); + assert_eq!((json["port"].as_u64(), json["token"].as_str()), (Some(4242), Some("t0ken"))); + assert_eq!(names, ["ctl.json", "victim.txt"], "no temporary file left behind"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn the_control_file_replaces_a_symlink_instead_of_writing_through_it() { + let dir = scratch(); + let (link, target) = (dir.join("ctl.json"), dir.join("victim.txt")); + std::fs::write(&target, "keep me").unwrap(); + #[cfg(unix)] + let made = std::os::unix::fs::symlink(&target, &link); + #[cfg(windows)] + let made = std::os::windows::fs::symlink_file(&target, &link); + if let Err(e) = made { + // Windows needs Developer Mode (or admin) for symlinks. + eprintln!("skipped: can't create a symlink here: {e}"); + let _ = std::fs::remove_dir_all(&dir); + return; + } + super::write_control_file(&link.to_string_lossy(), 4242, "t0ken").unwrap(); + assert_eq!(std::fs::read_to_string(&target).unwrap(), "keep me"); + assert!(!std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink()); + assert_eq!(written(&dir).0["token"].as_str(), Some("t0ken")); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn the_control_file_replaces_the_one_a_previous_run_left() { + let dir = scratch(); + std::fs::write(dir.join("ctl.json"), r#"{"port":1,"token":"old"}"#).unwrap(); + super::write_control_file(&dir.join("ctl.json").to_string_lossy(), 4242, "t0ken").unwrap(); + let (json, names) = written(&dir); + assert_eq!(json["token"].as_str(), Some("t0ken")); + assert_eq!(names, ["ctl.json"]); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(dir.join("ctl.json")).unwrap().permissions().mode(); + assert_eq!(mode & 0o077, 0, "only the owner can read the token: {mode:o}"); + } + let _ = std::fs::remove_dir_all(&dir); +} + +/// X11 startup notification and docks match a window to its launcher by `StartupWMClass`, +/// so it has to be the class the window carries: the app id, on Wayland and X11 alike. +#[test] +fn the_desktop_entry_names_the_window_class() { + let entry = include_str!("../../../packaging/linux/ai.storyteller.pdfcraft.desktop"); + let class = entry.lines().find_map(|line| line.strip_prefix("StartupWMClass=")); + assert_eq!(class, Some(super::APP_ID)); +} + +// Regression test for #246: `eprintln!` panics when standard error is a broken pipe (a GUI +// launch on Windows), and a panic inside the panic hook aborts the process. The start-up +// path and the hook must write to standard error without it. +#[test] +fn start_up_and_the_panic_hook_never_use_eprintln() { + let source = include_str!("main.rs"); + // Everything before the top-level test module is product code. + let product = source.split("\n#[cfg(test)]\nmod tests;").next().unwrap_or(source); + assert!(product.len() < source.len(), "found the end of the product code"); + let offenders: Vec<_> = product + .lines() + .enumerate() + .filter(|(_, line)| !line.trim_start().starts_with("//") && line.contains("eprintln!")) + .map(|(n, line)| format!("main.rs:{}: {}", n + 1, line.trim())) + .collect(); + assert!( + offenders.is_empty(), + "`eprintln!` panics on a broken standard error pipe; use `log::warn!` or `write_fmt` on `stderr()` and ignore the error:\n{}", + offenders.join("\n") + ); +} diff --git a/assets/app-icon/README.md b/assets/app-icon/README.md index 10fb2d4cf3..7c17ad0045 100644 --- a/assets/app-icon/README.md +++ b/assets/app-icon/README.md @@ -1,6 +1,6 @@ # PdfCraft app icon -PdfCraft app icon: an engraved lion's head on green +PdfCraft app icon: an engraved lion's head on red **Creature:** a lion, in a frontal head-and-shoulders portrait, mane running off the bottom of the tile. @@ -13,15 +13,14 @@ a full-bleed colour field, no frame or roundel, the animal looking at the viewer |---|---|---| | Ink | `#0b0b0c` | line work and the figure's contour | | Paper | `#efe9dc` | the figure (the lion's silhouette) | -| PdfCraft green (app colour) | `#12a58a` | the full-bleed field | +| PdfCraft red (app colour) | `#b70228` | the full-bleed field | **Tile:** `viewBox="0 0 512 512"`, a rounded square with `rx=112` that clips everything. Windows and Linux icons use the full-bleed tile. macOS icons put it on Apple's grid (an 824 px body centred on a transparent 1024 px canvas). **Provenance:** the project owner's original drawing, made in ArtCraft (2880 px, keyed to the palette), then -vectorised with craftrules `assets/logo-options/_tools/vectorize_tile.py` (potrace; no filtering or -warping). The source drawing is kept in craftrules at `assets/app-icons/pdfcraft/source.png`, not here. +vectorised (potrace; no filtering or warping). Licence: [LICENSE.txt](LICENSE.txt) (`MIT OR Apache-2.0`, like the repo). ## Files @@ -33,7 +32,7 @@ Licence: [LICENSE.txt](LICENSE.txt) (`MIT OR Apache-2.0`, like the repo). | `pdfcraft-1024.png` | 1024 px on Apple's grid; also the runtime Dock icon on macOS | | `pdfcraft.icns` | macOS icon (16–1024 px) | | `pdfcraft.ico` | Windows icon (16–256 px), embedded in `pdfcraft.exe` by `apps/pdfcraft/build.rs` | -| `pdfcraft-document.svg` | Windows PDF file icon master: a Lucide document outline, green badge and outlined PDF lettering; no font dependency | +| `pdfcraft-document.svg` | Windows PDF file icon master: a Lucide document outline, red badge and outlined PDF lettering; no font dependency | | `pdfcraft-document.ico` | Windows PDF file icon (16–256 px), embedded as resource 2 in `pdfcraft.exe` | | `hicolor/x/apps/ai.storyteller.pdfcraft.png` | Linux hicolor theme, 16–512 px; the 256 px one is the runtime icon on Windows and Linux | | `hicolor/scalable/apps/ai.storyteller.pdfcraft.svg` | Linux scalable icon (copy of the master) | diff --git a/assets/app-icon/hicolor/128x128/apps/ai.storyteller.pdfcraft.png b/assets/app-icon/hicolor/128x128/apps/ai.storyteller.pdfcraft.png index 20f4ca4393..b4ac96bc47 100644 Binary files a/assets/app-icon/hicolor/128x128/apps/ai.storyteller.pdfcraft.png and b/assets/app-icon/hicolor/128x128/apps/ai.storyteller.pdfcraft.png differ diff --git a/assets/app-icon/hicolor/16x16/apps/ai.storyteller.pdfcraft.png b/assets/app-icon/hicolor/16x16/apps/ai.storyteller.pdfcraft.png index be76e69b86..c3f4315212 100644 Binary files a/assets/app-icon/hicolor/16x16/apps/ai.storyteller.pdfcraft.png and b/assets/app-icon/hicolor/16x16/apps/ai.storyteller.pdfcraft.png differ diff --git a/assets/app-icon/hicolor/24x24/apps/ai.storyteller.pdfcraft.png b/assets/app-icon/hicolor/24x24/apps/ai.storyteller.pdfcraft.png index a00f8f9501..5ae21c7ded 100644 Binary files a/assets/app-icon/hicolor/24x24/apps/ai.storyteller.pdfcraft.png and b/assets/app-icon/hicolor/24x24/apps/ai.storyteller.pdfcraft.png differ diff --git a/assets/app-icon/hicolor/256x256/apps/ai.storyteller.pdfcraft.png b/assets/app-icon/hicolor/256x256/apps/ai.storyteller.pdfcraft.png index be413a715a..766624ec8c 100644 Binary files a/assets/app-icon/hicolor/256x256/apps/ai.storyteller.pdfcraft.png and b/assets/app-icon/hicolor/256x256/apps/ai.storyteller.pdfcraft.png differ diff --git a/assets/app-icon/hicolor/32x32/apps/ai.storyteller.pdfcraft.png b/assets/app-icon/hicolor/32x32/apps/ai.storyteller.pdfcraft.png index f9e2ef2b6e..ab6e8e9312 100644 Binary files a/assets/app-icon/hicolor/32x32/apps/ai.storyteller.pdfcraft.png and b/assets/app-icon/hicolor/32x32/apps/ai.storyteller.pdfcraft.png differ diff --git a/assets/app-icon/hicolor/48x48/apps/ai.storyteller.pdfcraft.png b/assets/app-icon/hicolor/48x48/apps/ai.storyteller.pdfcraft.png index 73971ee5f9..55256b293d 100644 Binary files a/assets/app-icon/hicolor/48x48/apps/ai.storyteller.pdfcraft.png and b/assets/app-icon/hicolor/48x48/apps/ai.storyteller.pdfcraft.png differ diff --git a/assets/app-icon/hicolor/512x512/apps/ai.storyteller.pdfcraft.png b/assets/app-icon/hicolor/512x512/apps/ai.storyteller.pdfcraft.png index 1d9ff4d11c..eb3699f9b8 100644 Binary files a/assets/app-icon/hicolor/512x512/apps/ai.storyteller.pdfcraft.png and b/assets/app-icon/hicolor/512x512/apps/ai.storyteller.pdfcraft.png differ diff --git a/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png b/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png index 14444f01bc..9bba411e39 100644 Binary files a/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png and b/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png differ diff --git a/assets/app-icon/hicolor/scalable/apps/ai.storyteller.pdfcraft.svg b/assets/app-icon/hicolor/scalable/apps/ai.storyteller.pdfcraft.svg index 3a1de9f076..5a8017ec4f 100644 --- a/assets/app-icon/hicolor/scalable/apps/ai.storyteller.pdfcraft.svg +++ b/assets/app-icon/hicolor/scalable/apps/ai.storyteller.pdfcraft.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/assets/app-icon/pdfcraft-1024.png b/assets/app-icon/pdfcraft-1024.png index 3c79b60279..d6cf213765 100644 Binary files a/assets/app-icon/pdfcraft-1024.png and b/assets/app-icon/pdfcraft-1024.png differ diff --git a/assets/app-icon/pdfcraft-document.ico b/assets/app-icon/pdfcraft-document.ico index 79967a800a..ec1ea4568e 100644 Binary files a/assets/app-icon/pdfcraft-document.ico and b/assets/app-icon/pdfcraft-document.ico differ diff --git a/assets/app-icon/pdfcraft-document.svg b/assets/app-icon/pdfcraft-document.svg index 603a740157..b9d2fbf3c9 100644 --- a/assets/app-icon/pdfcraft-document.svg +++ b/assets/app-icon/pdfcraft-document.svg @@ -4,10 +4,10 @@ - - + + - + diff --git a/assets/app-icon/pdfcraft-small.svg b/assets/app-icon/pdfcraft-small.svg index fb35ea2912..a8d6517b54 100644 --- a/assets/app-icon/pdfcraft-small.svg +++ b/assets/app-icon/pdfcraft-small.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/assets/app-icon/pdfcraft.icns b/assets/app-icon/pdfcraft.icns index 08369d5dd0..c41d1d10f0 100644 Binary files a/assets/app-icon/pdfcraft.icns and b/assets/app-icon/pdfcraft.icns differ diff --git a/assets/app-icon/pdfcraft.ico b/assets/app-icon/pdfcraft.ico index 6e1971d4a5..eedf34883a 100644 Binary files a/assets/app-icon/pdfcraft.ico and b/assets/app-icon/pdfcraft.ico differ diff --git a/assets/app-icon/pdfcraft.svg b/assets/app-icon/pdfcraft.svg index 3a1de9f076..5a8017ec4f 100644 --- a/assets/app-icon/pdfcraft.svg +++ b/assets/app-icon/pdfcraft.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/clippy.toml b/clippy.toml index 7f2fd53057..31e54da30f 100644 --- a/clippy.toml +++ b/clippy.toml @@ -13,3 +13,8 @@ disallowed-methods = [ { path = "egui::Ui::radio_value", reason = "use radio_value from crates/ui-egui/src/widgets.rs, which is outlined and filled" }, { path = "egui::RadioButton::new", reason = "use radio from crates/ui-egui/src/widgets.rs, which is outlined and filled" }, ] +cognitive-complexity-threshold = 30 + +disallowed-types = [ + { path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" }, +] diff --git a/contributors/people.toml b/contributors/people.toml index dd75a07314..fecfb68c90 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -1,13 +1,10 @@ -# Contributor names for the About window (craftrules standards/contributors.md). +# Contributor names for the About window. # # Everyone who contributes is credited by GitHub username automatically. Anything more is up to # you: add YOUR OWN entry below, in your own PR (commit it yourself). Never add someone else; a # maintainer may add a person only when that person asked, recording where in consent_source. # -# Easiest: python3 ../../craftrules/scripts/contributors.py --add-me . \ -# [--real-name "Ada Lovelace"] [--display-name "Ada"] [--sync-github-name] -# -# Or copy this block, replacing your-github-username (every field is optional): +# Copy this block, replacing your-github-username (every field is optional): # # [people.your-github-username] # real_name = "Your Name" # shown when the viewer picks "Real name" @@ -180,3 +177,9 @@ sync_github_name = true [people.dotCooCoo] real_name = "Robert Lee" sync_github_name = true + +[people.dylanmtaylor] +sync_github_name = true + +[people.Combiler01] +sync_github_name = true diff --git a/crates/a11y/src/alt.rs b/crates/a11y/src/alt.rs index 720b8a6a1e..fde1048e47 100644 --- a/crates/a11y/src/alt.rs +++ b/crates/a11y/src/alt.rs @@ -125,9 +125,9 @@ pub(crate) fn elements(doc: &Document) -> Vec { } /// The marked-content ids an element owns directly. -fn mcids(d: &Dict) -> Vec { +fn mcids(doc: &Document, d: &Dict) -> Vec { let mut out = Vec::new(); - let mut push = |o: &Object| match o { + let mut push = |o: &Object| match &*doc.resolve(o) { Object::Int(n) => out.push(*n), Object::Dict(m) if m.name(b"Type") == Some(b"MCR") => { if let Some(n) = m.get(b"MCID").and_then(Object::as_int) { @@ -323,7 +323,7 @@ pub fn figures(doc: &Document) -> Vec
{ let mut wanted: HashMap> = HashMap::new(); for (e, page) in &figures { if let Some(p) = page { - wanted.entry(*p).or_default().extend(mcids(&e.dict)); + wanted.entry(*p).or_default().extend(mcids(doc, &e.dict)); } } let bounds: HashMap> = @@ -333,7 +333,7 @@ pub fn figures(doc: &Document) -> Vec
{ .map(|(e, page)| { let alt = e.dict.get(b"Alt").map(|o| doc.resolve(o)).and_then(|o| o.as_string().map(|s| s.to_text())); let on_page = page.and_then(|p| bounds.get(&p)); - let bbox = on_page.and_then(|b| mcids(&e.dict).iter().filter_map(|id| b.get(id)).fold(None, |acc, b| Some(union(acc, *b)))); + let bbox = on_page.and_then(|b| mcids(doc, &e.dict).iter().filter_map(|id| b.get(id)).fold(None, |acc, b| Some(union(acc, *b)))); Figure { obj: e.object, page, alt, bbox } }) .collect() @@ -363,52 +363,73 @@ pub fn set_alt(doc: &mut Document, obj: ObjRef, alt: Option<&str>) -> Result<(), } /// The page's content entries: one stream, or the elements of a contents array. -fn content_entries(doc: &Document, page: &Dict) -> Option> { - let c = page.get(b"Contents")?; - Some(match &*doc.resolve(c) { +fn content_entries(doc: &Document, page: &Dict) -> Vec { + let Some(c) = page.get(b"Contents") else { return Vec::new() }; + match &*doc.resolve(c) { Object::Array(a) => a.clone(), Object::Stream(_) => vec![c.clone()], - _ => return None, - }) + _ => Vec::new(), + } } -fn marked(doc: &Document, props: &Dict, op: &pdfcraft_content::Op, ids: &HashSet) -> bool { - op.op == b"BDC" && bdc_mcid(doc, props, op).is_some_and(|n| ids.contains(&n)) -} +type DecodedStream = (Object, Option<(Stream, Vec)>); -/// Turn the figure's marked-content blocks into artifacts, rewriting only the streams that -/// contain them. Other content streams, and unknown entries on a rewritten stream, stay. -fn artifact_marked_content(doc: &mut Document, page: &pdfcraft_model::Page, ids: &HashSet) -> Result { - let Some(entries) = content_entries(doc, &page.dict) else { return Ok(false) }; - let props = page_properties(doc, &page.dict); - let mut streams: Vec)>> = Vec::with_capacity(entries.len()); +/// Decode the page's content streams within the page decode budget. `None` when a stream is +/// rejected because the budget is exhausted. +fn decode_page_streams(doc: &Document, page: &Dict) -> Option> { + let entries = content_entries(doc, page); + let mut streams = Vec::with_capacity(entries.len()); let mut budget = DecodeBudget::page(); - for entry in &entries { - match &*doc.resolve(entry) { + for entry in entries { + let decoded = match &*doc.resolve(&entry) { Object::Stream(s) => { let data = match budget.decode(s) { Ok(data) => data, - // Content past the budget may hold more of the figure: editing the rest - // would leave that part marked as a figure the tree no longer has. - Err(_) if budget.exhausted() => return Err(AltError::TooLarge), + // Content past the budget may hold more of the tag: editing the rest + // would leave that part marked as a tag the tree no longer has. + Err(_) if budget.exhausted() => return None, Err(_) => Vec::new(), }; - streams.push(Some((s.clone(), data))); + Some((s.clone(), data)) } - _ => streams.push(None), - } + _ => None, + }; + streams.push((entry, decoded)); } - let decoded: Vec<&[u8]> = streams.iter().flatten().map(|(_, bytes)| bytes.as_slice()).collect(); + Some(streams) +} + +fn marked_id(doc: &Document, props: &Dict, op: &pdfcraft_content::Op, ids: &HashSet) -> Option { + (op.op == b"BDC").then(|| bdc_mcid(doc, props, op)).flatten().filter(|n| ids.contains(n)) +} + +/// Turn the tag's marked-content blocks into artifacts, rewriting only the streams that +/// contain them. Other content streams, and unknown entries on a rewritten stream, stay. +/// With `require_all`, every id in `ids` must appear on the page before any stream is rewritten. +fn artifact_marked_content( + doc: &mut Document, + page: &pdfcraft_model::Page, + streams: Vec, + ids: &HashSet, + require_all: bool, +) -> Result { + let props = page_properties(doc, &page.dict); + let decoded: Vec<&[u8]> = streams.iter().filter_map(|(_, s)| s.as_ref().map(|(_, bytes)| bytes.as_slice())).collect(); let pieces = pdfcraft_content::Pieces::join(&decoded); - let edits: Vec<_> = - pieces.parse().ops.into_iter().filter(|op| marked(doc, &props, op, ids)).map(|op| (op.span, b"/Artifact BMC".to_vec())).collect(); - if edits.is_empty() { + let mut found = HashSet::new(); + let mut edits = Vec::new(); + for op in pieces.parse().ops { + if let Some(id) = marked_id(doc, &props, &op, ids) { + found.insert(id); + edits.push((op.span, b"/Artifact BMC".to_vec())); + } + } + if if require_all { found != *ids } else { found.is_empty() } { return Ok(false); } let mut spliced = pieces.splice(edits).into_iter(); - let mut next = Vec::with_capacity(entries.len()); - let mut changed = false; - for (entry, stream) in entries.into_iter().zip(streams) { + let mut next = Vec::with_capacity(streams.len()); + for (entry, stream) in streams { let Some((stream, old)) = stream else { next.push(entry); continue; @@ -421,7 +442,6 @@ fn artifact_marked_content(doc: &mut Document, page: &pdfcraft_model::Page, ids: next.push(entry); continue; } - changed = true; let mut dict = stream.dict; dict.remove(b"Length"); dict.remove(b"Filter"); @@ -431,28 +451,27 @@ fn artifact_marked_content(doc: &mut Document, page: &pdfcraft_model::Page, ids: // appear elsewhere in this array or on another page, where it must stay unchanged. next.push(Object::Ref(doc.add(stream))); } - if changed { - let contents = match next.as_slice() { - [one] => one.clone(), - _ => Object::Array(next), - }; - doc.update_dict(page.obj, |p| p.set(b"Contents".to_vec(), contents))?; - } - Ok(changed) + let contents = match next.as_slice() { + [one] => one.clone(), + _ => Object::Array(next), + }; + doc.update_dict(page.obj, |p| p.set(b"Contents".to_vec(), contents))?; + Ok(true) } /// Decorative figure: its marked content on the page becomes an artifact, and the element /// leaves the structure tree (and the parent tree). pub fn mark_decorative(doc: &mut Document, obj: ObjRef) -> Result<(), AltError> { let d = check_figure(doc, obj)?; - let ids: HashSet = mcids(&d).into_iter().collect(); + let ids: HashSet = mcids(doc, &d).into_iter().collect(); 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()) - && artifact_marked_content(doc, page, &ids)? - { + if let Some(page) = pg.and_then(|p| pages.iter().find(|x| x.obj == p)).filter(|_| !ids.is_empty()) { + let streams = decode_page_streams(doc, &page.dict).ok_or(AltError::TooLarge)?; // The parent tree no longer points those ids at the figure. - if let Some(key) = page.dict.get(b"StructParents").and_then(Object::as_int) { + if artifact_marked_content(doc, page, streams, &ids, false)? + && let Some(key) = page.dict.get(b"StructParents").and_then(Object::as_int) + { clear_parent_tree(doc, key, obj)?; } } @@ -484,54 +503,23 @@ pub fn mark_tag_artifact(doc: &mut Document, obj: ObjRef) -> Result<(), Artifact if has_child_structure(doc, &element.dict) { return Err(ArtifactError::HasChildren); } - let ids: HashSet = mcids(&element.dict).into_iter().collect(); + let ids: HashSet = mcids(doc, &element.dict).into_iter().collect(); if ids.is_empty() { return Err(ArtifactError::NoMarkedContent); } let pages = pdfcraft_model::pages(doc); let page_ref = element.page.ok_or(ArtifactError::NoPage)?; let page = pages.iter().find(|page| page.obj == page_ref).ok_or(ArtifactError::NoPage)?; - let mut content = Vec::new(); - if let Some(c) = page.dict.get(b"Contents") { - let mut budget = DecodeBudget::page(); - for data in budget.decode_contents(doc, c) { - match data { - Ok(data) => { - content.extend_from_slice(&data); - content.push(b' '); - } - // The page's /Contents is replaced with what was decoded: content past the - // budget would be dropped from the page. - Err(_) if budget.exhausted() => return Err(ArtifactError::TooLarge), - Err(_) => {} - } - } - } - let mut ops = pdfcraft_content::parse(&content).ops; - let mut found = HashSet::new(); - for op in &mut ops { - let id = (op.op == b"BDC") - .then(|| match op.operands.get(1) { - Some(Object::Dict(mark)) => mark.get(b"MCID").and_then(Object::as_int), - _ => None, - }) - .flatten() - .filter(|id| ids.contains(id)); - if let Some(id) = id { - *op = pdfcraft_content::Op::new("BMC", vec![Object::name("Artifact")]); - found.insert(id); - } - } - if found != ids { + let parent = element.dict.get(b"P").and_then(Object::as_ref).ok_or(ArtifactError::NoParent)?; + let replacement = replacement_kids(doc, parent, obj)?; + let streams = decode_page_streams(doc, &page.dict).ok_or(ArtifactError::TooLarge)?; + if !artifact_marked_content(doc, page, streams, &ids, true)? { return Err(ArtifactError::ContentNotFound); } - let stream = doc.add(Object::Stream(Stream::flate(Dict::new(), &pdfcraft_content::serialize_ops(&ops)))); - doc.update_dict(page.obj, |p| p.set(b"Contents".to_vec(), Object::Ref(stream)))?; if let Some(key) = page.dict.get(b"StructParents").and_then(Object::as_int) { clear_parent_tree(doc, key, obj).map_err(|e| ArtifactError::ParentTree(e.to_string()))?; } - let parent = element.dict.get(b"P").and_then(Object::as_ref).ok_or(ArtifactError::NoParent)?; - remove_from_parent(doc, parent, obj)?; + doc.update_dict(parent, |p| p.set(b"K".to_vec(), replacement))?; // Keep the retired object valid if another object holds an out-of-tree reference to it. doc.update_dict(obj, |d| { d.remove(b"P"); @@ -551,20 +539,18 @@ fn has_child_structure(doc: &Document, tag: &Dict) -> bool { tag.get(b"K").is_some_and(|items| child(doc, items)) } -fn remove_from_parent(doc: &mut Document, parent: ObjRef, obj: ObjRef) -> Result<(), ArtifactError> { +fn replacement_kids(doc: &Document, parent: ObjRef, obj: ObjRef) -> Result { let kids = doc.get(parent).as_dict().and_then(|p| p.get(b"K").cloned()).ok_or(ArtifactError::NoParent)?; - let replacement = match kids { + match kids { Object::Array(items) => { if items.iter().filter(|item| item.as_ref() == Some(obj)).count() != 1 { return Err(ArtifactError::AmbiguousParent); } - Object::Array(items.into_iter().filter(|item| item.as_ref() != Some(obj)).collect()) + Ok(Object::Array(items.into_iter().filter(|item| item.as_ref() != Some(obj)).collect())) } - Object::Ref(r) if r == obj => Object::Array(Vec::new()), - _ => return Err(ArtifactError::NoParent), - }; - doc.update_dict(parent, |p| p.set(b"K".to_vec(), replacement))?; - Ok(()) + Object::Ref(r) if r == obj => Ok(Object::Array(Vec::new())), + _ => Err(ArtifactError::NoParent), + } } /// Replace `obj` by null in the page's parent-tree array (`key`), in a flat /Nums tree. diff --git a/crates/a11y/src/headings.rs b/crates/a11y/src/headings.rs index 41c749ed3c..0fa31ccbb1 100644 --- a/crates/a11y/src/headings.rs +++ b/crates/a11y/src/headings.rs @@ -188,7 +188,10 @@ pub(crate) fn mcid_texts(doc: &Document, page: &Dict) -> HashMap { // Where each id's last glyph ended, in user space. let mut ends: HashMap = HashMap::new(); let mut gs = TextState::default(); - let mut saved: Vec = Vec::new(); + // With each saved state, the text and text line matrices: inside a text object `q`/`Q` + // save and restore them too (ISO 32000-2 §9.4.1, as corrected by the PDF Association's + // errata, issue 368), as the renderer does. + let mut saved: Vec<(TextState, Matrix, Matrix)> = Vec::new(); let mut unsaved = 0usize; let (mut tm, mut tlm) = (Matrix::IDENTITY, Matrix::IDENTITY); for op in pdfcraft_content::parse(&page_content(doc, page)).ops { @@ -209,12 +212,12 @@ pub(crate) fn mcid_texts(doc: &Document, page: &Dict) -> HashMap { b"EMC" => { stack.pop(); } - b"q" if saved.len() < MAX_SAVED => saved.push(gs.clone()), + b"q" if saved.len() < MAX_SAVED => saved.push((gs.clone(), tm, tlm)), b"q" => unsaved += 1, b"Q" if unsaved > 0 => unsaved -= 1, b"Q" => { if let Some(saved) = saved.pop() { - gs = saved; + (gs, tm, tlm) = saved; } } b"cm" => { diff --git a/crates/a11y/src/tests.rs b/crates/a11y/src/tests.rs index a6dcdba199..3024319ec3 100644 --- a/crates/a11y/src/tests.rs +++ b/crates/a11y/src/tests.rs @@ -1017,6 +1017,76 @@ fn named_property_lists_are_found_and_marked_decorative_like_inline_ones() { assert!(props.contains(b"P0") && props.contains(b"P1") && props.contains(b"Other"), "resources are left as they were"); } +#[test] +fn mark_tag_artifact_finds_named_property_lists_and_indirect_mcrs() { + use pdfcraft_cos::{Dict, ObjRef, Object}; + let mut doc = named_figures_doc(); + let mcr = doc.add(Object::Dict([(b"Type".to_vec(), Object::name("MCR")), (b"MCID".to_vec(), Object::Int(0))].into_iter().collect::())); + doc.update_dict(ObjRef::new(7, 0), |d| d.set(b"K".to_vec(), Object::Ref(mcr))).unwrap(); + assert_eq!(figures(&doc)[0].bbox, Some([10.0, 150.0, 50.0, 170.0]), "indirect MCR is resolved for figure bounds"); + + for (tag, gone) in [(ObjRef::new(7, 0), "/P0"), (ObjRef::new(8, 0), "/P1")] { + mark_tag_artifact(&mut doc, tag).unwrap(); + 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(gone), "{text}"); + } + assert_eq!(tags(&doc).into_iter().map(|t| t.object.num).collect::>(), [6]); + let nums = doc.get(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::Null, Object::Null])]))); + let r = check(&doc, &Options { rules: [Rule::FiguresAltText, Rule::TaggedContent].into(), pages: None }); + assert_eq!(failed(&r), Vec::::new(), "{:?}", r.results); +} + +#[test] +fn mark_tag_artifact_validates_parent_and_all_mcids_before_mutating_document() { + use pdfcraft_cos::{ObjRef, Object}; + + // Missing /P on the tag must fail before touching /Contents or /ParentTree. + let mut no_parent = figures_doc(); + let before_contents = no_parent.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Contents").cloned(); + let before_pt = no_parent.get(ObjRef::new(9, 0)).as_ref().clone(); + no_parent + .update_dict(ObjRef::new(7, 0), |d| { + d.remove(b"P"); + }) + .unwrap(); + assert_eq!(mark_tag_artifact(&mut no_parent, ObjRef::new(7, 0)), Err(ArtifactError::NoParent)); + assert_eq!(no_parent.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Contents").cloned(), before_contents); + assert_eq!(*no_parent.get(ObjRef::new(9, 0)), before_pt); + + // Duplicate entry in parent's /K must fail with AmbiguousParent before touching /Contents or /ParentTree. + let mut dup_parent = figures_doc(); + dup_parent + .update_dict(ObjRef::new(6, 0), |d| { + d.set(b"K".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0)), Object::Ref(ObjRef::new(7, 0))])); + }) + .unwrap(); + assert_eq!(mark_tag_artifact(&mut dup_parent, ObjRef::new(7, 0)), Err(ArtifactError::AmbiguousParent)); + assert_eq!(dup_parent.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Contents").cloned(), before_contents); + assert_eq!(*dup_parent.get(ObjRef::new(9, 0)), before_pt); + + // Partial MCID match (MCID 0 present, MCID 99 absent) must fail with ContentNotFound in + // mark_tag_artifact without mutating /Contents, while mark_decorative still converts MCID 0. + let mut partial = figures_doc(); + partial.update_dict(ObjRef::new(7, 0), |d| d.set(b"K".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(99)]))).unwrap(); + assert_eq!(mark_tag_artifact(&mut partial, ObjRef::new(7, 0)), Err(ArtifactError::ContentNotFound)); + assert_eq!(partial.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Contents").cloned(), before_contents); + assert_eq!(*partial.get(ObjRef::new(9, 0)), before_pt); + mark_decorative(&mut partial, ObjRef::new(7, 0)).unwrap(); + assert_ne!(partial.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Contents").cloned(), before_contents); + assert_ne!(*partial.get(ObjRef::new(9, 0)), before_pt); + + // When none of a figure's MCIDs appear on the page, mark_decorative leaves /Contents and /ParentTree untouched. + let mut absent = figures_doc(); + absent.update_dict(ObjRef::new(7, 0), |d| d.set(b"K".to_vec(), Object::Int(99))).unwrap(); + mark_decorative(&mut absent, ObjRef::new(7, 0)).unwrap(); + assert_eq!(absent.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Contents").cloned(), before_contents); + assert_eq!(*absent.get(ObjRef::new(9, 0)), before_pt); +} + fn ref_(n: u32) -> Option { Some(pdfcraft_cos::ObjRef::new(n, 0)) } @@ -1235,6 +1305,19 @@ fn headings_drawn_glyph_by_glyph_keep_their_words() { ); } +/// Inside a text object `q`/`Q` save and restore the text matrices (ISO 32000-2 §9.4.1, as +/// corrected by the PDF Association's errata, issue 368), as the renderer does: a word whose +/// glyphs continue after a `Q` stays one word. +#[test] +fn q_inside_a_text_object_restores_the_text_matrices() { + let doc = heading_page(&[ + "BT /F1 20 Tf 72 700 Td (Chap) Tj q 0 -100 Td Q (ter One) Tj ET".into(), + "BT /F1 20 Tf 72 650 Td (Chap) Tj q 1 0 0 1 300 300 Tm Q (ter Two) Tj ET".into(), + ]); + let titles: Vec = headings(&doc).into_iter().map(|h| h.title).collect(); + assert_eq!(titles, ["Chapter One", "Chapter Two"]); +} + #[test] fn an_untagged_document_has_no_headings() { let mut objs = headings_doc(); diff --git a/crates/a11y/tests/cross_stream.rs b/crates/a11y/tests/cross_stream.rs index ad613a2939..d0a2f48204 100644 --- a/crates/a11y/tests/cross_stream.rs +++ b/crates/a11y/tests/cross_stream.rs @@ -99,14 +99,20 @@ fn shown_strings(ops: &[Op]) -> Vec { #[test] fn mark_decorative_leaves_the_other_stream_untouched() { - let mut doc = tagged_figure_two_streams("0 0 1 rg 300 600 100 50 re f", "BT /F1 12 Tf 72 500 Td (After) Tj ET"); - let before = doc.get(ObjRef::new(11, 0)).clone(); - mark_decorative(&mut doc, ObjRef::new(8, 0)).expect("mark decorative succeeds"); - assert!(figures(&doc).is_empty()); - assert_eq!(*doc.get(ObjRef::new(11, 0)), *before, "the second stream holds no figure and keeps its object"); - let page = doc.get(ObjRef::new(3, 0)); - let contents = page.as_dict().unwrap().get(b"Contents").unwrap().as_array().unwrap(); - assert_eq!(contents[1].as_ref(), Some(ObjRef::new(11, 0)), "the untouched stream must not be replaced by a re-encoded copy"); + for use_artifact in [false, true] { + let mut doc = tagged_figure_two_streams("0 0 1 rg 300 600 100 50 re f", "BT /F1 12 Tf 72 500 Td (After) Tj ET"); + let before = doc.get(ObjRef::new(11, 0)).clone(); + if use_artifact { + mark_tag_artifact(&mut doc, ObjRef::new(8, 0)).expect("mark tag artifact succeeds"); + } else { + mark_decorative(&mut doc, ObjRef::new(8, 0)).expect("mark decorative succeeds"); + } + assert!(figures(&doc).is_empty()); + assert_eq!(*doc.get(ObjRef::new(11, 0)), *before, "the second stream holds no figure and keeps its object"); + let page = doc.get(ObjRef::new(3, 0)); + let contents = page.as_dict().unwrap().get(b"Contents").unwrap().as_array().unwrap(); + assert_eq!(contents[1].as_ref(), Some(ObjRef::new(11, 0)), "the untouched stream must not be replaced by a re-encoded copy"); + } } /// A and B supply different operands to R's BDC. Reusing R must not let the first diff --git a/crates/annot/README.md b/crates/annot/README.md index 8108201835..999baec20a 100644 --- a/crates/annot/README.md +++ b/crates/annot/README.md @@ -30,7 +30,7 @@ let index = add_annotation(&mut doc, &NewAnnotation { page, shape, style, conten add_reply(&mut doc, page, index, "Agreed", "Ada", &meta)?; set_review_state(&mut doc, page, index, ReviewState::Accepted, "Ada", &meta)?; move_annotation(&mut doc, page, index, dx, dy, &meta)?; -set_style(&mut doc, page, index, Some(rgb), Some(0.5), Some(2.0), &meta)?; +set_style(&mut doc, page, index, StyleChange::new(Some(rgb), Some(0.5), Some(2.0), None), &meta)?; delete_annotation(&mut doc, page, index)?; signature_image(&doc, page, index)?; // embedded image XObject of Fill & Sign signature/initials ``` diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs index 530438db6d..909250ef26 100644 --- a/crates/annot/src/appearance.rs +++ b/crates/annot/src/appearance.rs @@ -16,6 +16,11 @@ pub use pdfcraft_fonts::{helvetica_width as text_width, wrap}; use crate::{NOTE_SIZE, Rgb, n}; +mod symbols; + +pub(crate) use symbols::display_stamp; +use symbols::{CAPTION_SIZE, attach_icon, ellipse, line_caption, line_layout, note_icon, stamp}; + /// Length of an arrowhead for a line of width `w`. pub fn arrow_size(w: f64) -> f64 { 6.0 + 3.0 * w.max(0.0) @@ -368,16 +373,64 @@ fn caret_layout(d: &Dict, rect: [f64; 4]) -> ([f64; 4], Option<(f64, f64, f64)>) (caret, Some((caret[2] + gap * scale, caret[1] + size * 0.18, size))) } +#[derive(Clone, Copy)] +struct AppearanceContext<'a> { + dict: &'a Dict, + subtype: &'a [u8], + rect: [f64; 4], + cloudy: bool, + opacity: f64, + stroke: Option, + width: f64, +} + +#[derive(Default)] +struct AppearanceDrawing { + resources: Dict, + content: String, + tail: Vec, +} + +impl AppearanceDrawing { + /// Initialize an appearance drawing buffer and configure its graphics state for opacity and blend mode. + fn new(context: &AppearanceContext<'_>) -> Self { + let mut drawing = Self::default(); + if let Some(gs) = ext_gstate( + context.opacity, + context.subtype == b"Highlight" || (context.subtype == b"Ink" && context.dict.name(b"IT") == Some(b"FreehandHighlight")), + ) { + drawing.resources.set(b"ExtGState".to_vec(), Object::Dict(gs)); + drawing.content.push_str("/GS0 gs\n"); + } + drawing + } +} + /// Draw the normal appearance of an annotation, or `None` if this subtype/variant isn't supported. /// Text the standard fonts can't draw comes out as `?`: [`undrawable_text`] says which, and /// [`crate::set_appearance`] marks the annotation with it (`pdfcraft_fonts::UNDRAWN_MARK`). pub fn build(d: &Dict) -> Option { + let context = appearance_context(d)?; + let drawing = AppearanceDrawing::new(&context); + match context.subtype { + b"Text" => return draw_icon(&context, "Note", [1.0, 0.82, 0.0], drawing), + b"FileAttachment" => return draw_icon(&context, "PushPin", [0.0, 0.47, 0.84], drawing), + b"Stamp" => return draw_stamp(&context, drawing), + b"FreeText" => return draw_free_text(&context, drawing), + _ => {} + } + let mut drawing = drawing; + draw_body(&context, &mut drawing)?; + finish_appearance(&context, drawing) +} + +fn appearance_context(d: &Dict) -> Option> { // Imported measurement appearances may include leaders, captions and formatting that // this builder cannot reproduce. Preserve them rather than silently losing detail. if d.contains(b"Measure") && !d.contains(b"PCMeasureValue") { return None; } - let subtype = d.name(b"Subtype")?.to_vec(); + let subtype = d.name(b"Subtype")?; let rect = nums(d, b"Rect").filter(|r| r.len() == 4)?; let rect = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])]; // Cloudy borders (§12.5.4) are drawn for polygons only. @@ -385,1053 +438,576 @@ pub fn build(d: &Dict) -> Option { if cloudy && subtype != b"Polygon" { return None; } - let opacity = d.get(b"CA").and_then(|o| o.as_f64()).unwrap_or(1.0).clamp(0.0, 1.0); - let stroke = color(d, b"C")?; - let w = border_width(d); - let mut res = Dict::new(); - let mut c = String::new(); - // Content after `c` that isn't UTF-8 (WinAnsi text). - let mut tail: Vec = Vec::new(); - let markup = matches!(subtype.as_slice(), b"Highlight" | b"Underline" | b"StrikeOut" | b"Squiggly"); - if let Some(gs) = ext_gstate(opacity, subtype == b"Highlight") { - res.set(b"ExtGState".to_vec(), Object::Dict(gs)); - c.push_str("/GS0 gs\n"); - } - match subtype.as_slice() { - b"Text" => { - let col = stroke.unwrap_or([1.0, 0.82, 0.0]); - let icon = d.name(b"Name").map(|n| String::from_utf8_lossy(n).into_owned()).unwrap_or_else(|| "Note".into()); - c.push_str(¬e_icon(&icon, col)); - return Some(form([0.0, 0.0, NOTE_SIZE, NOTE_SIZE], c.as_bytes(), res)); - } - b"FileAttachment" => { - let col = stroke.unwrap_or([0.0, 0.47, 0.84]); - let icon = d.name(b"Name").map(|n| String::from_utf8_lossy(n).into_owned()).unwrap_or_else(|| "PushPin".into()); - c.push_str(&attach_icon(&icon, col)); - return Some(form([0.0, 0.0, NOTE_SIZE, NOTE_SIZE], c.as_bytes(), res)); - } - _ if markup => { - let q = nums(d, b"QuadPoints").filter(|q| !q.is_empty() && q.len() % 8 == 0)?; - let col = stroke?; - for quad in q.as_chunks::<8>().0.iter() { - let p = |i: usize| (quad[2 * i], quad[2 * i + 1]); - let (p1, p2, p3, p4) = (p(0), p(1), p(2), p(3)); - let h = (p1.0 - p3.0).hypot(p1.1 - p3.1); - // A point a fraction `t` of the way from the bottom edge to the top edge. - let at = |bottom: (f64, f64), top: (f64, f64), t: f64| (bottom.0 + (top.0 - bottom.0) * t, bottom.1 + (top.1 - bottom.1) * t); - match subtype.as_slice() { - b"Highlight" => { - c.push_str(&rg(col)); - c.push_str(&format!( - "{} {} m {} {} l {} {} l {} {} l h f\n", - n(p1.0), - n(p1.1), - n(p2.0), - n(p2.1), - n(p4.0), - n(p4.1), - n(p3.0), - n(p3.1) - )); - } - b"Underline" | b"StrikeOut" => { - let lw = (h * 0.07).clamp(0.5, 3.0); - let t = if subtype == b"Underline" { 0.08 } else { 0.42 }; - let (a, b) = (at(p3, p1, t), at(p4, p2, t)); - c.push_str(&format!("{}{} w\n{} {} m {} {} l S\n", rg_stroke(col), n(lw), n(a.0), n(a.1), n(b.0), n(b.1))); - } - _ => { - // Squiggly: a zigzag along the bottom of the quad. - let lw = (h * 0.05).clamp(0.5, 2.0); - let amp = (h * 0.06).max(0.75); - let len = (p4.0 - p3.0).hypot(p4.1 - p3.1); - let step = (h / 4.0).max(1.5); - let steps = ((len / step).ceil() as usize).clamp(1, 10_000); - c.push_str(&format!("{}{} w 1 j\n", rg_stroke(col), n(lw))); - for i in 0..=steps { - let t = i as f64 / steps as f64; - let base = (p3.0 + (p4.0 - p3.0) * t, p3.1 + (p4.1 - p3.1) * t); - let up = if i % 2 == 0 { amp * 2.0 } else { 0.0 }; - let (ux, uy) = if h > 0.0 { ((p1.0 - p3.0) / h, (p1.1 - p3.1) / h) } else { (0.0, 1.0) }; - let (x, y) = (base.0 + ux * up, base.1 + uy * up); - c.push_str(&format!("{} {} {}\n", n(x), n(y), if i == 0 { "m" } else { "l" })); - } - c.push_str("S\n"); - } - } - } - } - b"Redact" => { - // While marked: the outline of each area (the fill comes when applied). - let q = nums(d, b"QuadPoints").filter(|q| !q.is_empty() && q.len() % 8 == 0)?; - let col = stroke.unwrap_or([0.89, 0.13, 0.13]); - c.push_str(&format!("{}1 w\n", rg_stroke(col))); - for quad in q.as_chunks::<8>().0.iter() { - c.push_str(&format!( - "{} {} m {} {} l {} {} l {} {} l h S\n", - n(quad[0]), - n(quad[1]), - n(quad[2]), - n(quad[3]), - n(quad[6]), - n(quad[7]), - n(quad[4]), - n(quad[5]) - )); - } - } - b"Square" | b"Circle" => { - let fill = interior_color(d, None); - if stroke.is_none() && fill.is_none() { - return Some(form(rect, c.as_bytes(), res)); - } - let inset = w / 2.0; - let [x0, y0, x1, y1] = [rect[0] + inset, rect[1] + inset, rect[2] - inset, rect[3] - inset]; - if let Some(f) = fill { - c.push_str(&rg(f)); - } - if let Some(s) = stroke { - c.push_str(&format!("{}{} w\n{}", rg_stroke(s), n(w), dash(d))); - } - if subtype == b"Square" { - c.push_str(&format!("{} {} {} {} re\n", n(x0), n(y0), n(x1 - x0), n(y1 - y0))); - } else { - c.push_str(&ellipse(x0, y0, x1, y1)); - } - c.push_str(match (fill.is_some(), stroke.is_some() && w > 0.0) { - (true, true) => "B\n", - (true, false) => "f\n", - (false, true) => "S\n", - (false, false) => "n\n", - }); - } - b"Line" => { - let l = nums(d, b"L").filter(|l| l.len() == 4 && l.iter().all(|x| x.is_finite()))?; - let col = stroke?; - let ends: Vec> = match d.get(b"LE") { - None => vec![b"None".to_vec(), b"None".to_vec()], - Some(o) => o.as_array()?.iter().map(|e| e.as_name().map(<[u8]>::to_vec)).collect::>()?, - }; - if ends.len() != 2 || ends.iter().any(|e| !known_ending(e)) { - return None; - } - let fill = interior_color(d, Some(col)).unwrap_or(col); - let layout = line_layout(d, [(l[0], l[1]), (l[2], l[3])], w); - let [p, q] = layout.line; - c.push_str(&format!("{}{}{} w 1 J 1 j\n{}", rg_stroke(col), rg(fill), n(w), dash(d))); - for [s, e] in layout.leaders.iter().chain(&layout.strokes).chain(&layout.tick) { - c.push_str(&format!("{} {} m {} {} l S\n", n(s.0), n(s.1), n(e.0), n(e.1))); - } - c.push_str("[] 0 d\n"); - for (end, (tip, from)) in ends.iter().zip([(p, q), (q, p)]) { - if !line_end(&mut c, end, tip, from, w) { - return None; - } - } - if let Some((text, m)) = layout.caption { - // The caption is black, in the standard font the measurement label uses. - tail.extend( - format!("q 0 g BT /Helv {} Tf {} {} {} {} {} {} Tm ", n(CAPTION_SIZE), n(m[0]), n(m[1]), n(m[2]), n(m[3]), n(m[4]), n(m[5])) - .bytes(), - ); - // WinAnsi bytes go into the stream as-is, not through UTF-8. - tail.extend(literal(&win_ansi(&text))); - tail.extend_from_slice(b" Tj ET Q\n"); - res.set(b"Font".to_vec(), helvetica_resource()); - } - } - b"Polygon" | b"PolyLine" => { - let v = nums(d, b"Vertices").filter(|v| v.len() >= 4 && v.len() % 2 == 0 && v.iter().all(|x| x.is_finite()))?; - let pts: Vec<(f64, f64)> = v.as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect(); - let col = stroke; - let closed = subtype == b"Polygon"; - let fill = if closed { interior_color(d, None) } else { None }; - let ends = if subtype == b"PolyLine" { - match d.get(b"LE") { - None => None, - Some(o) => { - let names: Vec> = - o.as_array().filter(|a| a.len() == 2)?.iter().map(|e| e.as_name().map(<[u8]>::to_vec)).collect::>()?; - if names.iter().any(|e| !known_ending(e)) { - return None; - } - Some(names) - } - } - } else { - None - }; - if let Some(f) = fill { - c.push_str(&rg(f)); - } - if let Some(s) = col { - c.push_str(&format!("{}{} w 1 J 1 j\n{}", rg_stroke(s), n(w), dash(d))); - } - if cloudy { - let intensity = d.get(b"BE").and_then(|b| b.as_dict()).and_then(|b| b.get(b"I")).and_then(|i| i.as_f64()).unwrap_or(1.0); - c.push_str(&cloud_path(&pts, cloud_radius(w) * intensity.clamp(0.5, 2.0))); - } else { - for (i, p) in pts.iter().enumerate() { - c.push_str(&format!("{} {} {}\n", n(p.0), n(p.1), if i == 0 { "m" } else { "l" })); - } - if closed { - c.push_str("h\n"); - } - } - c.push_str(match (fill.is_some(), col.is_some() && w > 0.0) { - (true, true) => "B\n", - (true, false) => "f\n", - (false, true) => "S\n", - (false, false) => "n\n", - }); - if let Some(ends) = ends { - let col = col?; - let (Some(start), Some(end)) = (ends.first(), ends.get(1)) else { return None }; - let (Some(&tip0), Some(&from0)) = (pts.first(), pts.get(1)) else { return None }; - let (Some(&tip1), Some(&from1)) = (pts.last(), pts.get(pts.len().saturating_sub(2))) else { return None }; - let fill = interior_color(d, Some(col)).unwrap_or(col); - c.push_str(&format!("[] 0 d\n{}", rg(fill))); - if !line_end(&mut c, start, tip0, from0, w) || !line_end(&mut c, end, tip1, from1, w) { - return None; - } - } - } - b"Caret" => { - // A filled caret: two curved flanks meeting at the top centre. - let col = stroke.unwrap_or([0.0, 0.47, 0.84]); - let (caret, pilcrow) = caret_layout(d, rect); - let [x0, y0, x1, y1] = caret; - let (cx, h) = ((x0 + x1) / 2.0, y1 - y0); - c.push_str(&format!( - "{}{} {} m {} {} {} {} {} {} c {} {} {} {} {} {} c h f\n", - rg(col), - n(x0), - n(y0), - n(cx - (x1 - x0) * 0.1), - n(y0 + h * 0.2), - n(cx), - n(y1 - h * 0.25), - n(cx), - n(y1), - n(cx), - n(y1 - h * 0.25), - n(cx + (x1 - x0) * 0.1), - n(y0 + h * 0.2), - n(x1), - n(y0) - )); - if let Some((x, y, size)) = pilcrow { - // `/Sy /P`: a new paragraph symbol beside the caret (Table 183), in its colour. - // 0xB6 is the pilcrow in WinAnsiEncoding. - let mut out = c.into_bytes(); - out.extend(format!("BT /Helv {} Tf {} {} Td (", n(size), n(x), n(y)).bytes()); - out.push(0xB6); - out.extend_from_slice(b") Tj ET\n"); - res.set(b"Font".to_vec(), helvetica_resource()); - return Some(form(rect, &out, res)); - } - } - b"Ink" => { - let col = stroke?; - let list = d.get(b"InkList")?.as_array()?; - // `/BS` gives an ink stroke its width and dash pattern (Table 182). - c.push_str(&format!("{}{} w 1 J 1 j\n{}", rg_stroke(col), n(w), dash(d))); - for s in list { - let pts: Vec = s.as_array()?.iter().map(|o| o.as_f64()).collect::>()?; - let pts: Vec<(f64, f64)> = pts.as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect(); - let Some(first) = pts.first() else { continue }; - c.push_str(&format!("{} {} m\n", n(first.0), n(first.1))); - match pts.as_slice() { - [_] => c.push_str(&format!("{} {} l\n", n(first.0 + 0.01), n(first.1))), - [_, end] => c.push_str(&format!("{} {} l\n", n(end.0), n(end.1))), - _ => c.push_str(&smooth_curve(&pts)), - } - c.push_str("S\n"); - } - } - b"Stamp" => { - // A typed signature: filled outlines normalised to the rectangle. - if let Some(outline) = d.get(b"PCOutline").and_then(|o| o.as_array()) { - let [x0, y0, x1, y1] = rect; - let (w, h) = (x1 - x0, y1 - y0); - c.push_str(&rg(stroke.unwrap_or([0.0; 3]))); - for contour in outline { - let v: Vec = contour.as_array().map(|a| a.iter().filter_map(Object::as_f64).collect()).unwrap_or_default(); - for (i, p) in v.as_chunks::<2>().0.iter().enumerate() { - c.push_str(&format!("{} {} {}\n", n(x0 + p[0] * w), n(y0 + p[1] * h), if i == 0 { "m" } else { "l" })); - } - if v.len() >= 6 { - c.push_str("h\n"); - } - } - c.push_str("f\n"); - return Some(form(rect, c.as_bytes(), res)); - } - // A custom stamp: its picture (an image, or a form mapped to /PCPictureSize) fills - // the rectangle. An image signature added to a turned page (`/PCPictureRotate`) gets - // the standard counter-rotation as its form `/Matrix`: viewers fit the turned - // bounding box to `/Rect` (ISO 32000-2 §12.5.5), so it reads upright as displayed. - if let Some(pic) = d.get(b"PCPicture").and_then(Object::as_ref) { - let [x0, y0, x1, y1] = rect; - let (w, h) = (x1 - x0, y1 - y0); - let image = matches!(d.get(b"PCPictureImage"), Some(Object::Bool(true))); - let place = if image { - format!("{} 0 0 {} {} {} cm", n(w), n(h), n(x0), n(y0)) - } else { - let size = nums(d, b"PCPictureSize").filter(|s| s.len() == 2 && s[0] > 0.0 && s[1] > 0.0)?; - format!("{} 0 0 {} {} {} cm", n(w / size[0]), n(h / size[1]), n(x0), n(y0)) - }; - c.push_str(&format!("q {place} /Pic Do Q\n")); - let mut xo = Dict::new(); - xo.set(b"Pic".to_vec(), Object::Ref(pic)); - res.set(b"XObject".to_vec(), Object::Dict(xo)); - let mut stream = form(rect, c.as_bytes(), res); - let turn = picture_turn(d); - if image && turn != 0 { - // The page's view matrix without its offset: display axes in user space. - let m = pdfcraft_model::view_matrix_for(turn, [0.0; 4]); - let matrix = [m[0], m[1], m[2], m[3], 0.0, 0.0]; - stream.dict.set(b"Matrix".to_vec(), Object::Array(matrix.iter().map(|x| Object::Real(*x)).collect())); - } - return Some(stream); - } - // Only PdfCraft's own Fill & Sign marks are drawn here. - let name = d.name(b"Name")?; - let col = stroke.unwrap_or([0.0; 3]); - // A mark or stamp placed on a turned page is drawn upright in its displayed box, then - // mapped into `/Rect` the way the page maps display space into user space. Viewers - // fit that box to `/Rect`; the page rotation then shows it the right way up. - let turn = stamp_turn(d); - let draw = if turn == 0 { - rect - } else { - let (rw, rh) = (rect[2] - rect[0], rect[3] - rect[1]); - let (dw, dh) = if matches!(turn, 90 | 270) { (rh, rw) } else { (rw, rh) }; - if dw < 1.0 || dh < 1.0 { - return None; - } - [0.0, 0.0, dw, dh] - }; - let turned = |mut stream: Stream| { - if turn != 0 { - let m = pdfcraft_model::view_matrix_for(turn, rect); - stream.dict.set(b"Matrix".to_vec(), Object::Array(m.iter().copied().map(Object::Real).collect())); - } - stream - }; - let [x0, y0, x1, y1] = draw; - let (w, h) = (x1 - x0, y1 - y0); - let lw = w.min(h) * 0.12; - match name { - b"PCCheck" => c.push_str(&format!( - "{}{} w 1 J 1 j\n{} {} m {} {} l {} {} l S\n", - rg_stroke(col), - n(lw), - n(x0 + w * 0.15), - n(y0 + h * 0.5), - n(x0 + w * 0.4), - n(y0 + h * 0.2), - n(x0 + w * 0.88), - n(y0 + h * 0.85) - )), - b"PCCross" => c.push_str(&format!( - "{}{} w 1 J\n{} {} m {} {} l {} {} m {} {} l S\n", - rg_stroke(col), - n(lw), - n(x0 + w * 0.18), - n(y0 + h * 0.18), - n(x1 - w * 0.18), - n(y1 - h * 0.18), - n(x0 + w * 0.18), - n(y1 - h * 0.18), - n(x1 - w * 0.18), - n(y0 + h * 0.18) - )), - b"PCDot" => { - let r = w.min(h) * 0.3; - c.push_str(&rg(col)); - c.push_str(&ellipse(x0 + w / 2.0 - r, y0 + h / 2.0 - r, x0 + w / 2.0 + r, y0 + h / 2.0 + r)); - c.push_str("f\n"); - } - b"PCLine" => c.push_str(&format!( - "{}{} w 1 J\n{} {} m {} {} l S\n", - rg_stroke(col), - n(h.clamp(0.5, 2.0)), - n(x0), - n(y0 + h / 2.0), - n(x1), - n(y0 + h / 2.0) - )), - other => { - // Only stamps PdfCraft made: others keep their own artwork. - if !matches!(d.get(b"PCStamp"), Some(Object::Bool(true))) { - return None; - } - let kind = crate::StampKind::from_name(other)?; - let by = d.get(b"PCByLine").and_then(|o| o.as_string()).map(PdfString::to_text); - let sign_here = kind.group() == crate::StampGroup::SignHere; - return Some(turned(stamp(kind.label(), sign_here, draw, stroke.unwrap_or(kind.color()), by.as_deref(), opacity, res))); - } - } - if turn != 0 { - return Some(turned(form(draw, c.as_bytes(), res))); - } - } - b"FreeText" => { - let (text_color, size) = parse_da(d); - let bg = stroke; - let bw = if d.contains(b"BS") || d.contains(b"Border") { border_width(d) } else { 0.0 }; - // A callout: the leader line (arrowhead at its first point), and the text box inside - // `/Rect` by `/RD`. - // Text added to a turned page is drawn upright in its displayed box, which the form - // `/Matrix` then maps into `/Rect` (as for stamps). Callouts keep user-space axes. - let turn = if d.contains(b"CL") { 0 } else { text_turn(d) }; - let (rect, matrix) = if turn == 0 { - (rect, None) - } else { - let (rw, rh) = (rect[2] - rect[0], rect[3] - rect[1]); - let (dw, dh) = if matches!(turn, 90 | 270) { (rh, rw) } else { (rw, rh) }; - if dw < 1.0 || dh < 1.0 { - return None; - } - ([0.0, 0.0, dw, dh], Some(pdfcraft_model::view_matrix_for(turn, rect))) - }; - let full = rect; - let mut rect = rect; - if d.contains(b"CL") { - let cl = nums(d, b"CL").filter(|l| l.len() == 4 || l.len() == 6)?; - let rd = nums(d, b"RD").filter(|r| r.len() == 4 && r.iter().all(|x| *x >= 0.0))?; - rect = [full[0] + rd[0], full[1] + rd[1], full[2] - rd[2], full[3] - rd[3]]; - if rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 { - return None; - } - let end = match d.get(b"LE") { - None => b"None".to_vec(), - Some(o) => o.as_name()?.to_vec(), - }; - if !known_ending(&end) { - return None; - } - let lw = bw.max(0.5); - let pts: Vec<(f64, f64)> = cl.as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect(); - if pts.len() < 2 || pts.iter().any(|p| !p.0.is_finite() || !p.1.is_finite()) { - return None; - } - c.push_str(&format!("{}{}{} w 1 J 1 j\n", rg_stroke(text_color), rg(bg.unwrap_or([1.0; 3])), n(lw))); - for (i, p) in pts.iter().enumerate() { - c.push_str(&format!("{} {} {}\n", n(p.0), n(p.1), if i == 0 { "m" } else { "l" })); - } - c.push_str("S\n"); - if !line_end(&mut c, &end, pts[0], pts[1], lw) { - return None; - } - } - if let Some(bg) = bg { - c.push_str(&format!("{}{} {} {} {} re f\n", rg(bg), n(rect[0]), n(rect[1]), n(rect[2] - rect[0]), n(rect[3] - rect[1]))); - } - if bw > 0.0 { - let h = bw / 2.0; - c.push_str(&format!( - "{}{} w\n{}{} {} {} {} re S\n[] 0 d\n", - rg_stroke(text_color), - n(bw), - dash(d), - n(rect[0] + h), - n(rect[1] + h), - n(rect[2] - rect[0] - bw), - n(rect[3] - rect[1] - bw) - )); - } - let text = d.get(b"Contents").and_then(|o| o.as_string()).map(PdfString::to_text).unwrap_or_default(); - let pad = 2.0 + bw; - let width = (rect[2] - rect[0] - 2.0 * pad).max(1.0); - let q = d.int(b"Q").unwrap_or(0); - c.push_str(&format!( - "{} {} {} {} re W n\nBT\n/Helv {} Tf\n{}", - n(rect[0]), - n(rect[1]), - n(rect[2] - rect[0]), - n(rect[3] - rect[1]), - n(size), - rg(text_color) - )); - let mut out = c.into_bytes(); - let mut y = rect[3] - pad - size * 0.9; - for line in wrap(&text, size, width) { - if y < rect[1] - size { - break; - } - let lw = text_width(&line, size); - let x = match q { - 1 => rect[0] + pad + (width - lw) / 2.0, - 2 => rect[2] - pad - lw, - _ => rect[0] + pad, - }; - out.extend(format!("1 0 0 1 {} {} Tm ", n(x), n(y)).bytes()); - out.extend(literal(&win_ansi(&line))); - out.extend_from_slice(b" Tj\n"); - y -= size * 1.2; - } - out.extend_from_slice(b"ET\n"); - res.set(b"Font".to_vec(), helvetica_resource()); - let mut stream = form(full, &out, res); - if let Some(m) = matrix { - stream.dict.set(b"Matrix".to_vec(), Object::Array(m.iter().copied().map(Object::Real).collect())); - } - return Some(stream); - } - _ => return None, + Some(AppearanceContext { + dict: d, + subtype, + rect, + cloudy, + opacity: d.get(b"CA").and_then(|o| o.as_f64()).unwrap_or(1.0).clamp(0.0, 1.0), + stroke: color(d, b"C")?, + width: border_width(d), + }) +} + +fn draw_body(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> Option<()> { + match context.subtype { + b"Highlight" | b"Underline" | b"StrikeOut" | b"Squiggly" => draw_markup(context, drawing), + b"Redact" => draw_redaction(context, drawing), + b"Square" | b"Circle" => draw_box(context, drawing), + b"Line" => draw_line(context, drawing), + b"Polygon" | b"PolyLine" => draw_polygon(context, drawing), + b"Caret" => draw_caret(context, drawing), + b"Ink" => draw_ink(context, drawing), + _ => None, } +} + +fn finish_appearance(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing) -> Option { // PdfCraft measurement captions are kept separate from the comment's free-form text. // A restyle regenerates the path and its value together. - let mut out = c.into_bytes(); - out.extend(tail); - if let Some(value) = d.get(b"PCMeasureValue").and_then(Object::as_string) { + let mut out = drawing.content.into_bytes(); + out.extend(drawing.tail); + if let Some(value) = context.dict.get(b"PCMeasureValue").and_then(Object::as_string) { let value = value.to_text(); - if value.chars().count() <= 256 && matches!(subtype.as_slice(), b"Line" | b"PolyLine" | b"Polygon") { + if value.chars().count() <= 256 && matches!(context.subtype, b"Line" | b"PolyLine" | b"Polygon") { let size = 10.0; - let x = (rect[0] + rect[2] - text_width(&value, size)) * 0.5; - let y = rect[3] - 12.0; - let col = stroke.unwrap_or([0.0, 0.47, 0.84]); + let x = (context.rect[0] + context.rect[2] - text_width(&value, size)) * 0.5; + let y = context.rect[3] - 12.0; + let col = context.stroke.unwrap_or([0.0, 0.47, 0.84]); out.extend(format!("{}BT /Helv {} Tf {} {} Td ", rg(col), n(size), n(x), n(y)).bytes()); // WinAnsi bytes (e.g. 0xB2 for "²") go into the stream as-is, not through UTF-8. out.extend(literal(&win_ansi(&value))); out.extend_from_slice(b" Tj ET\n"); - res.set(b"Font".to_vec(), helvetica_resource()); + drawing.resources.set(b"Font".to_vec(), helvetica_resource()); + } + } + Some(form(context.rect, &out, drawing.resources)) +} + +fn draw_markup(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> Option<()> { + let d = context.dict; + let subtype = context.subtype; + let stroke = context.stroke; + let c = &mut drawing.content; + let q = nums(d, b"QuadPoints").filter(|q| !q.is_empty() && q.len() % 8 == 0)?; + let col = stroke?; + for quad in q.as_chunks::<8>().0.iter() { + let p = |i: usize| (quad[2 * i], quad[2 * i + 1]); + let (p1, p2, p3, p4) = (p(0), p(1), p(2), p(3)); + let h = (p1.0 - p3.0).hypot(p1.1 - p3.1); + // A point a fraction `t` of the way from the bottom edge to the top edge. + let at = |bottom: (f64, f64), top: (f64, f64), t: f64| (bottom.0 + (top.0 - bottom.0) * t, bottom.1 + (top.1 - bottom.1) * t); + match subtype { + b"Highlight" => { + c.push_str(&rg(col)); + c.push_str(&format!("{} {} m {} {} l {} {} l {} {} l h f\n", n(p1.0), n(p1.1), n(p2.0), n(p2.1), n(p4.0), n(p4.1), n(p3.0), n(p3.1))); + } + b"Underline" | b"StrikeOut" => { + let lw = (h * 0.07).clamp(0.5, 3.0); + let t = if subtype == b"Underline" { 0.08 } else { 0.42 }; + let (a, b) = (at(p3, p1, t), at(p4, p2, t)); + c.push_str(&format!("{}{} w\n{} {} m {} {} l S\n", rg_stroke(col), n(lw), n(a.0), n(a.1), n(b.0), n(b.1))); + } + _ => { + // Squiggly: a zigzag along the bottom of the quad. + let lw = (h * 0.05).clamp(0.5, 2.0); + let amp = (h * 0.06).max(0.75); + let len = (p4.0 - p3.0).hypot(p4.1 - p3.1); + let step = (h / 4.0).max(1.5); + let steps = ((len / step).ceil() as usize).clamp(1, 10_000); + c.push_str(&format!("{}{} w 1 j\n", rg_stroke(col), n(lw))); + for i in 0..=steps { + let t = i as f64 / steps as f64; + let base = (p3.0 + (p4.0 - p3.0) * t, p3.1 + (p4.1 - p3.1) * t); + let up = if i % 2 == 0 { amp * 2.0 } else { 0.0 }; + let (ux, uy) = if h > 0.0 { ((p1.0 - p3.0) / h, (p1.1 - p3.1) / h) } else { (0.0, 1.0) }; + let (x, y) = (base.0 + ux * up, base.1 + uy * up); + c.push_str(&format!("{} {} {}\n", n(x), n(y), if i == 0 { "m" } else { "l" })); + } + c.push_str("S\n"); + } } } - Some(form(rect, &out, res)) + Some(()) } -/// The label and colour of a standard rubber stamp name (ISO 32000-2 Table 184), for a stamp -/// another program wrote without an appearance. Names are matched byte for byte, as PDF names -/// are compared (§7.3.5). Eight are stamps of PdfCraft's palette; the other six take a label of -/// their own and the palette's colour for their meaning. -fn standard_stamp(name: &[u8]) -> Option<(&'static str, Rgb)> { - use crate::StampKind::*; - let palette = [Approved, NotApproved, NotForPublicRelease, Confidential, Final, ForComment, Draft, ForPublicRelease]; - if let Some(kind) = palette.into_iter().find(|k| k.name().as_bytes() == name) { - return Some((kind.label(), kind.color())); +fn draw_redaction(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> Option<()> { + let d = context.dict; + let stroke = context.stroke; + let c = &mut drawing.content; + // While marked: the outline of each area (the fill comes when applied). + let q = nums(d, b"QuadPoints").filter(|q| !q.is_empty() && q.len() % 8 == 0)?; + let col = stroke.unwrap_or([0.89, 0.13, 0.13]); + c.push_str(&format!("{}1 w\n", rg_stroke(col))); + for quad in q.as_chunks::<8>().0.iter() { + c.push_str(&format!( + "{} {} m {} {} l {} {} l {} {} l h S\n", + n(quad[0]), + n(quad[1]), + n(quad[2]), + n(quad[3]), + n(quad[6]), + n(quad[7]), + n(quad[4]), + n(quad[5]) + )); } - let (label, like) = match name { - b"Experimental" => ("EXPERIMENTAL", Draft), - b"AsIs" => ("AS IS", Draft), - b"Expired" => ("EXPIRED", NotApproved), - b"Sold" => ("SOLD", Approved), - b"Departmental" => ("DEPARTMENTAL", Draft), - b"TopSecret" => ("TOP SECRET", Confidential), - _ => return None, - }; - Some((label, like.color())) + Some(()) } -/// The neutral grey of a stamp whose name has no drawing. -const PLACEHOLDER_GREY: Rgb = [0.45, 0.45, 0.45]; - -/// The display appearance of a rubber stamp another program wrote without one: the stamp its -/// `/Name` names (Draft when there is none, Table 184's default) in its `/C` colour or the -/// stamp's own, or for any other name a thin grey frame with both diagonals, so the stamp stays -/// visible and can be selected. The frame is grey whatever the `/C`: it stands in for a stamp -/// that cannot be drawn, so it takes none of that stamp's colour. The name is never drawn as text. -/// -/// For display only ([`crate::with_missing_appearances`]): [`build`] declines these stamps, so -/// edits (restyling, flattening) never write this drawing into the file. `None` for any other -/// annotation, one of PdfCraft's own stamps, a dictionary with an `/AP` (a stamp's own artwork is -/// never replaced; the display copy passes the dictionary without an `/AP` that has no normal -/// appearance), or a `/Rect` that isn't finite or is under a point wide or high. -pub(crate) fn display_stamp(d: &Dict) -> Option { - let own = matches!(d.get(b"PCStamp"), Some(Object::Bool(true))) || d.contains(b"PCPicture") || d.contains(b"PCOutline"); - if d.name(b"Subtype") != Some(b"Stamp") || own || d.contains(b"AP") { - return None; +fn draw_box(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> Option<()> { + let d = context.dict; + let subtype = context.subtype; + let rect = context.rect; + let stroke = context.stroke; + let w = context.width; + let c = &mut drawing.content; + let fill = interior_color(d, None); + if stroke.is_none() && fill.is_none() { + return Some(()); } - let rect = nums(d, b"Rect")?; - let [a, b, c, e] = rect.as_slice() else { return None }; - let rect = [a.min(*c), b.min(*e), a.max(*c), b.max(*e)]; - if !rect.iter().all(|v| v.is_finite()) || rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 { - return None; + let inset = w / 2.0; + let [x0, y0, x1, y1] = [rect[0] + inset, rect[1] + inset, rect[2] - inset, rect[3] - inset]; + if let Some(f) = fill { + c.push_str(&rg(f)); } - let opacity = d.get(b"CA").and_then(|o| o.as_f64()).unwrap_or(1.0).clamp(0.0, 1.0); - // A malformed `/C` falls back to the stamp's own colour rather than hiding the stamp. - let stroke = color(d, b"C").flatten(); - let mut res = Dict::new(); - if let Some(gs) = ext_gstate(opacity, false) { - res.set(b"ExtGState".to_vec(), Object::Dict(gs)); - } - // A stamp whose intent (`/IT`, PDF 2.0) is an image or a snapshot has no `/Name` to default. - let stamp_intent = d.name(b"IT").is_none_or(|it| it == b"Stamp"); - let name = d.name(b"Name").or(stamp_intent.then_some(b"Draft".as_slice())); - if let Some((label, col)) = name.and_then(standard_stamp) { - return Some(stamp(label, false, rect, stroke.unwrap_or(col), None, opacity, res)); - } - let [x0, y0, x1, y1] = rect; - let (a0, b0, a1, b1) = (x0 + 0.5, y0 + 0.5, x1 - 0.5, y1 - 0.5); - let mut c = String::new(); - if opacity < 1.0 { - c.push_str("/GS0 gs\n"); + if let Some(s) = stroke { + c.push_str(&format!("{}{} w\n{}", rg_stroke(s), n(w), dash(d))); } - c.push_str(&format!( - "{}1 w\n{} {} {} {} re\n{} {} m {} {} l\n{} {} m {} {} l\nS\n", - // Grey whatever the `/C`: the placeholder is not the stamp, so it takes none of its colour. - rg_stroke(PLACEHOLDER_GREY), - n(a0), - n(b0), - n(a1 - a0), - n(b1 - b0), - n(a0), - n(b0), - n(a1), - n(b1), - n(a0), - n(b1), - n(a1), - n(b0) - )); - Some(form(rect, c.as_bytes(), res)) -} - -/// Size of a line's caption, as of a measurement's label. -const CAPTION_SIZE: f64 = 10.0; - -/// Characters of a line's caption drawn at most, as for a measurement's label. -const MAX_CAPTION_CHARS: usize = 256; - -/// Leader lengths and caption offsets (Table 178) are held within this many points (200 inches, -/// the largest page side ISO 32000-2 Annex C allows), so an absurd value can't overflow. -const MAX_LINE_EXTENT: f64 = 14_400.0; - -/// A Table 178 length or offset: finite and within [`MAX_LINE_EXTENT`], 0 when absent or not one. -fn line_length(v: Option) -> f64 { - v.filter(|v| v.is_finite()).map_or(0.0, |v| v.clamp(-MAX_LINE_EXTENT, MAX_LINE_EXTENT)) -} - -/// The caption a Line with `/Cap true` shows (ISO 32000-2 Table 178): its `/Contents`, else the -/// plain text of its `/RC`, on one line and cut to [`MAX_CAPTION_CHARS`]. `None` without a -/// caption, and for PdfCraft's own measurements, whose label (`/PCMeasureValue`) is the caption. -fn line_caption(d: &Dict) -> Option { - if !matches!(d.get(b"Cap"), Some(Object::Bool(true))) || d.contains(b"PCMeasureValue") { + if subtype == b"Square" { + c.push_str(&format!("{} {} {} {} re\n", n(x0), n(y0), n(x1 - x0), n(y1 - y0))); + } else { + c.push_str(&ellipse(x0, y0, x1, y1)); + } + c.push_str(match (fill.is_some(), stroke.is_some() && w > 0.0) { + (true, true) => "B\n", + (true, false) => "f\n", + (false, true) => "S\n", + (false, false) => "n\n", + }); + Some(()) +} + +fn draw_line(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> Option<()> { + let d = context.dict; + let stroke = context.stroke; + let w = context.width; + let c = &mut drawing.content; + let res = &mut drawing.resources; + let tail = &mut drawing.tail; + let l = nums(d, b"L").filter(|l| l.len() == 4 && l.iter().all(|x| x.is_finite()))?; + let col = stroke?; + let ends: Vec> = match d.get(b"LE") { + None => vec![b"None".to_vec(), b"None".to_vec()], + Some(o) => o.as_array()?.iter().map(|e| e.as_name().map(<[u8]>::to_vec)).collect::>()?, + }; + if ends.len() != 2 || ends.iter().any(|e| !known_ending(e)) { return None; } - let text = |key: &[u8]| d.get(key).and_then(Object::as_string).map(PdfString::to_text); - let raw = text(b"Contents").filter(|t| !t.trim().is_empty()).or_else(|| text(b"RC").map(|rc| rich_plain(&rc)))?; - let words: Vec<&str> = raw.split_whitespace().collect(); - let caption: String = words.join(" ").chars().filter(|c| !c.is_control()).take(MAX_CAPTION_CHARS).collect(); - (!caption.is_empty()).then_some(caption) -} - -/// The text of rich text (§12.7.4.3, XHTML): its markup dropped, the five XML entities and -/// numeric character references read, block ends read as spaces. -fn rich_plain(rc: &str) -> String { - let mut out = String::new(); - let mut tag: Option = None; - // Rich text past this is not caption material; it bounds the work. - for ch in rc.chars().take(MAX_CAPTION_CHARS * 64) { - match (&mut tag, ch) { - (None, '<') => tag = Some(String::new()), - (Some(name), '>') => { - let name = - name.trim_start_matches('/').split(|c: char| c.is_whitespace() || c == '/').next().unwrap_or_default().to_ascii_lowercase(); - if matches!(name.as_str(), "p" | "br" | "div" | "li") { - out.push(' '); - } - tag = None; - } - (Some(name), c) => { - if name.len() < 16 { - name.push(c); - } - } - (None, c) => out.push(c), + let fill = interior_color(d, Some(col)).unwrap_or(col); + let layout = line_layout(d, [(l[0], l[1]), (l[2], l[3])], w); + let [p, q] = layout.line; + c.push_str(&format!("{}{}{} w 1 J 1 j\n{}", rg_stroke(col), rg(fill), n(w), dash(d))); + for [s, e] in layout.leaders.iter().chain(&layout.strokes).chain(&layout.tick) { + c.push_str(&format!("{} {} m {} {} l S\n", n(s.0), n(s.1), n(e.0), n(e.1))); + } + c.push_str("[] 0 d\n"); + for (end, (tip, from)) in ends.iter().zip([(p, q), (q, p)]) { + if !line_end(c, end, tip, from, w) { + return None; } } - let mut text = String::new(); - let mut rest = out.as_str(); - while let Some(at) = rest.find('&') { - text.push_str(rest.get(..at).unwrap_or_default()); - let after = rest.get(at + 1..).unwrap_or_default(); - let entity = after.find(';').filter(|&end| end <= 10).and_then(|end| Some((after.get(..end)?, end))); - let decoded = entity.and_then(|(name, end)| { - let c = match name { - "amp" => '&', - "lt" => '<', - "gt" => '>', - "quot" => '"', - "apos" => '\'', - _ => { - let num = name.strip_prefix('#')?; - let code = match num.strip_prefix(['x', 'X']) { - Some(hex) => u32::from_str_radix(hex, 16).ok()?, - None => num.parse().ok()?, - }; - char::from_u32(code)? + if let Some((text, m)) = layout.caption { + // The caption is black, in the standard font the measurement label uses. + tail.extend( + format!("q 0 g BT /Helv {} Tf {} {} {} {} {} {} Tm ", n(CAPTION_SIZE), n(m[0]), n(m[1]), n(m[2]), n(m[3]), n(m[4]), n(m[5])).bytes(), + ); + // WinAnsi bytes go into the stream as-is, not through UTF-8. + tail.extend(literal(&win_ansi(&text))); + tail.extend_from_slice(b" Tj ET Q\n"); + res.set(b"Font".to_vec(), helvetica_resource()); + } + Some(()) +} + +fn draw_polygon(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> Option<()> { + let d = context.dict; + let subtype = context.subtype; + let stroke = context.stroke; + let w = context.width; + let cloudy = context.cloudy; + let c = &mut drawing.content; + let v = nums(d, b"Vertices").filter(|v| v.len() >= 4 && v.len() % 2 == 0 && v.iter().all(|x| x.is_finite()))?; + let pts: Vec<(f64, f64)> = v.as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect(); + let col = stroke; + let closed = subtype == b"Polygon"; + let fill = if closed { interior_color(d, None) } else { None }; + let ends = if subtype == b"PolyLine" { + match d.get(b"LE") { + None => None, + Some(o) => { + let names: Vec> = + o.as_array().filter(|a| a.len() == 2)?.iter().map(|e| e.as_name().map(<[u8]>::to_vec)).collect::>()?; + if names.iter().any(|e| !known_ending(e)) { + return None; } - }; - Some((c, end)) - }); - match decoded { - Some((c, end)) => { - text.push(c); - rest = after.get(end + 1..).unwrap_or_default(); - } - None => { - text.push('&'); - rest = after; + Some(names) } } + } else { + None + }; + if let Some(f) = fill { + c.push_str(&rg(f)); } - text.push_str(rest); - text -} - -/// Where a Line annotation's parts go (ISO 32000-2 §12.5.6.7, Table 178). -#[derive(Debug, Default)] -struct LineLayout { - /// The line itself, start to end: `/L`, or `/L` moved out by its leader lines. - line: [(f64, f64); 2], - /// Strokes along the line: the whole line, or the two pieces either side of an inline caption. - strokes: Vec<[(f64, f64); 2]>, - /// The leader lines, from `/L` (past the `/LLO` gap) to the line and on by `/LLE`. - leaders: Vec<[(f64, f64); 2]>, - /// A short stroke from the line to a caption `/CO` moved off it. - tick: Option<[(f64, f64); 2]>, - /// The caption and its text matrix. - caption: Option<(String, [f64; 6])>, -} - -/// Lay out a Line annotation from `/L` = `[a b]`, line width `w`, and its leader and caption -/// entries. -/// -/// Leader lines: a positive `/LL` puts the line on the left of travel from `a` to `b` (above `/L` -/// for a line drawn left to right), so the leader lines, followed from the line to `/L`, turn -/// clockwise from the direction of travel; a negative one puts it on the right. The line sits -/// `|LL| + LLO` from `/L`. `/LLE` and `/LLO` are non-negative: a negative one counts as 0. -/// -/// Caption (`/Cap true`): centred on the line's midpoint and turned with it so it reads left to -/// right (bottom to top for a vertical line). `/CP /Inline` (the default) centres it on the line, -/// which is broken around it; `/Top` sets it on top of the unbroken line. `/CO [h v]` moves it `h` -/// along the line and `v` across it (positive: up in reading direction); a caption moved off an -/// inline line gets a tick from the line to it. -/// -/// A zero-length or overflowing `/L` has no direction: it gets neither leader lines nor caption. -/// Every point is finite; if anything derived from the entries is not, they are left out. -fn line_layout(d: &Dict, [a, b]: [(f64, f64); 2], w: f64) -> LineLayout { - let plain = LineLayout { line: [a, b], strokes: vec![[a, b]], ..LineLayout::default() }; - let (dx, dy) = (b.0 - a.0, b.1 - a.1); - let len = dx.hypot(dy); - if !len.is_finite() || len <= 0.0 { - return plain; - } - let (ux, uy) = (dx / len, dy / len); - let mut out = LineLayout { line: [a, b], ..LineLayout::default() }; - let ll = line_length(d.get(b"LL").and_then(Object::as_f64)); - if ll != 0.0 { - // The unit normal from `/L` towards the line: left of travel for a positive /LL. - let s = ll.signum(); - let (nx, ny) = (-uy * s, ux * s); - let llo = line_length(d.get(b"LLO").and_then(Object::as_f64)).max(0.0); - let lle = line_length(d.get(b"LLE").and_then(Object::as_f64)).max(0.0); - let at = |p: (f64, f64), k: f64| (p.0 + nx * k, p.1 + ny * k); - let off = ll.abs() + llo; - out.line = [at(a, off), at(b, off)]; - out.leaders = vec![[at(a, llo), at(a, off + lle)], [at(b, llo), at(b, off + lle)]]; + if let Some(s) = col { + c.push_str(&format!("{}{} w 1 J 1 j\n{}", rg_stroke(s), n(w), dash(d))); } - let [p, _] = out.line; - let along = |t: f64| (p.0 + ux * t, p.1 + uy * t); - let mut gap = None; - if let Some(text) = line_caption(d) { - let size = CAPTION_SIZE; - let width = text_width(&text, size); - // Reading direction, and "up" across it. - let (tx, ty) = if ux < 0.0 || (ux == 0.0 && uy < 0.0) { (-ux, -uy) } else { (ux, uy) }; - let (vx, vy) = (-ty, tx); - let co = nums(d, b"CO").filter(|c| c.len() == 2).unwrap_or_default(); - let h = line_length(co.first().copied()); - let v = line_length(co.get(1).copied()); - let top = d.name(b"CP") == Some(b"Top"); - // The caption's centre, as a distance along the line from `p`. - let centre = len / 2.0 + h * (tx * ux + ty * uy); - let mid = along(centre); - // Baseline across the line: on top of it, or with the text centred on it. - let base = if top { w.max(0.0) / 2.0 + 2.0 + v } else { v - size * 0.35 }; - let origin = (mid.0 - tx * width / 2.0 + vx * base, mid.1 - ty * width / 2.0 + vy * base); - out.caption = Some((text, [tx, ty, -ty, tx, origin.0, origin.1])); - if !top && v.abs() < size / 2.0 { - let half = width / 2.0 + 2.0; - gap = Some(((centre - half).clamp(0.0, len), (centre + half).clamp(0.0, len))); - } else if !top { - let reach = v - v.signum() * size * 0.4; - out.tick = Some([mid, (mid.0 + vx * reach, mid.1 + vy * reach)]); + if cloudy { + let intensity = d.get(b"BE").and_then(|b| b.as_dict()).and_then(|b| b.get(b"I")).and_then(|i| i.as_f64()).unwrap_or(1.0); + c.push_str(&cloud_path(&pts, cloud_radius(w) * intensity.clamp(0.5, 2.0))); + } else { + for (i, p) in pts.iter().enumerate() { + c.push_str(&format!("{} {} {}\n", n(p.0), n(p.1), if i == 0 { "m" } else { "l" })); + } + if closed { + c.push_str("h\n"); + } + } + c.push_str(match (fill.is_some(), col.is_some() && w > 0.0) { + (true, true) => "B\n", + (true, false) => "f\n", + (false, true) => "S\n", + (false, false) => "n\n", + }); + if let Some(ends) = ends { + let col = col?; + let (Some(start), Some(end)) = (ends.first(), ends.get(1)) else { return None }; + let (Some(&tip0), Some(&from0)) = (pts.first(), pts.get(1)) else { return None }; + let (Some(&tip1), Some(&from1)) = (pts.last(), pts.get(pts.len().saturating_sub(2))) else { return None }; + let fill = interior_color(d, Some(col)).unwrap_or(col); + c.push_str(&format!("[] 0 d\n{}", rg(fill))); + if !line_end(c, start, tip0, from0, w) || !line_end(c, end, tip1, from1, w) { + return None; + } + } + Some(()) +} + +fn draw_caret(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> Option<()> { + let d = context.dict; + let rect = context.rect; + let stroke = context.stroke; + let c = &mut drawing.content; + // A filled caret: two curved flanks meeting at the top centre. + let col = stroke.unwrap_or([0.0, 0.47, 0.84]); + let (caret, pilcrow) = caret_layout(d, rect); + let [x0, y0, x1, y1] = caret; + let (cx, h) = ((x0 + x1) / 2.0, y1 - y0); + c.push_str(&format!( + "{}{} {} m {} {} {} {} {} {} c {} {} {} {} {} {} c h f\n", + rg(col), + n(x0), + n(y0), + n(cx - (x1 - x0) * 0.1), + n(y0 + h * 0.2), + n(cx), + n(y1 - h * 0.25), + n(cx), + n(y1), + n(cx), + n(y1 - h * 0.25), + n(cx + (x1 - x0) * 0.1), + n(y0 + h * 0.2), + n(x1), + n(y0) + )); + if let Some((x, y, size)) = pilcrow { + // `/Sy /P`: a new paragraph symbol beside the caret (Table 183), in its colour. + // 0xB6 is the pilcrow in WinAnsiEncoding. + drawing.tail.extend(format!("BT /Helv {} Tf {} {} Td (", n(size), n(x), n(y)).bytes()); + drawing.tail.push(0xB6); + drawing.tail.extend_from_slice(b") Tj ET\n"); + drawing.resources.set(b"Font".to_vec(), helvetica_resource()); + } + Some(()) +} + +fn draw_ink(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> Option<()> { + let d = context.dict; + let stroke = context.stroke; + let w = context.width; + let c = &mut drawing.content; + let col = stroke?; + let list = d.get(b"InkList")?.as_array()?; + // `/BS` gives an ink stroke its width and dash pattern (Table 182). + c.push_str(&format!("{}{} w 1 J 1 j\n{}", rg_stroke(col), n(w), dash(d))); + for s in list { + let pts: Vec = s.as_array()?.iter().map(|o| o.as_f64()).collect::>()?; + let pts: Vec<(f64, f64)> = pts.as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect(); + let Some(first) = pts.first() else { continue }; + c.push_str(&format!("{} {} m\n", n(first.0), n(first.1))); + match pts.as_slice() { + [_] => c.push_str(&format!("{} {} l\n", n(first.0 + 0.01), n(first.1))), + [_, end] => c.push_str(&format!("{} {} l\n", n(end.0), n(end.1))), + _ => c.push_str(&smooth_curve(&pts)), + } + c.push_str("S\n"); + } + Some(()) +} + +fn draw_stamp(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing) -> Option { + let d = context.dict; + let rect = context.rect; + let stroke = context.stroke; + let opacity = context.opacity; + let c = &mut drawing.content; + let mut res = std::mem::take(&mut drawing.resources); + // A typed signature: filled outlines normalised to the rectangle. + if let Some(outline) = d.get(b"PCOutline").and_then(|o| o.as_array()) { + let [x0, y0, x1, y1] = rect; + let (w, h) = (x1 - x0, y1 - y0); + c.push_str(&rg(stroke.unwrap_or([0.0; 3]))); + for contour in outline { + let v: Vec = contour.as_array().map(|a| a.iter().filter_map(Object::as_f64).collect()).unwrap_or_default(); + for (i, p) in v.as_chunks::<2>().0.iter().enumerate() { + c.push_str(&format!("{} {} {}\n", n(x0 + p[0] * w), n(y0 + p[1] * h), if i == 0 { "m" } else { "l" })); + } + if v.len() >= 6 { + c.push_str("h\n"); + } + } + c.push_str("f\n"); + return Some(form(rect, c.as_bytes(), res)); + } + // A custom stamp: its picture (an image, or a form mapped to /PCPictureSize) fills + // the rectangle. An image signature added to a turned page (`/PCPictureRotate`) gets + // the standard counter-rotation as its form `/Matrix`: viewers fit the turned + // bounding box to `/Rect` (ISO 32000-2 §12.5.5), so it reads upright as displayed. + if let Some(pic) = d.get(b"PCPicture").and_then(Object::as_ref) { + let [x0, y0, x1, y1] = rect; + let (w, h) = (x1 - x0, y1 - y0); + let image = matches!(d.get(b"PCPictureImage"), Some(Object::Bool(true))); + let place = if image { + format!("{} 0 0 {} {} {} cm", n(w), n(h), n(x0), n(y0)) + } else { + let size = nums(d, b"PCPictureSize").filter(|s| s.len() == 2 && s[0] > 0.0 && s[1] > 0.0)?; + format!("{} 0 0 {} {} {} cm", n(w / size[0]), n(h / size[1]), n(x0), n(y0)) + }; + c.push_str(&format!("q {place} /Pic Do Q\n")); + let mut xo = Dict::new(); + xo.set(b"Pic".to_vec(), Object::Ref(pic)); + res.set(b"XObject".to_vec(), Object::Dict(xo)); + let mut stream = form(rect, c.as_bytes(), res); + let turn = picture_turn(d); + if image && turn != 0 { + // The page's view matrix without its offset: display axes in user space. + let m = pdfcraft_model::view_matrix_for(turn, [0.0; 4]); + let matrix = [m[0], m[1], m[2], m[3], 0.0, 0.0]; + stream.dict.set(b"Matrix".to_vec(), Object::Array(matrix.iter().map(|x| Object::Real(*x)).collect())); + } + return Some(stream); + } + // Only PdfCraft's own Fill & Sign marks are drawn here. + let name = d.name(b"Name")?; + let col = stroke.unwrap_or([0.0; 3]); + // A mark or stamp placed on a turned page is drawn upright in its displayed box, then + // mapped into `/Rect` the way the page maps display space into user space. Viewers + // fit that box to `/Rect`; the page rotation then shows it the right way up. + let turn = stamp_turn(d); + let draw = if turn == 0 { + rect + } else { + let (rw, rh) = (rect[2] - rect[0], rect[3] - rect[1]); + let (dw, dh) = if matches!(turn, 90 | 270) { (rh, rw) } else { (rw, rh) }; + if dw < 1.0 || dh < 1.0 { + return None; } - } - out.strokes = match gap { - Some((g0, g1)) => [(0.0, g0), (g1, len)].into_iter().filter(|(s, e)| e > s).map(|(s, e)| [along(s), along(e)]).collect(), - None => vec![out.line], + [0.0, 0.0, dw, dh] }; - let finite = |q: &(f64, f64)| q.0.is_finite() && q.1.is_finite(); - let all_finite = - out.line.iter().chain(out.strokes.iter().flatten()).chain(out.leaders.iter().flatten()).chain(out.tick.iter().flatten()).all(finite) - && out.caption.as_ref().is_none_or(|(_, m)| m.iter().all(|x| x.is_finite())); - if all_finite { out } else { plain } -} - -/// A rubber stamp: a rounded frame (a pointed tag for sign-here stamps) with the label in bold -/// capitals, and the dynamic stamps' "By … at …" line. -fn stamp(label: &str, sign_here: bool, rect: [f64; 4], col: Rgb, by: Option<&str>, opacity: f64, mut res: Dict) -> Stream { - let [x0, y0, x1, y1] = rect; - let h = y1 - y0; - let mut c = String::new(); - if opacity < 1.0 { - c.push_str("/GS0 gs\n"); - } - let lw = (h * 0.07).clamp(1.0, 3.0); - let inset = lw / 2.0 + 0.5; - if sign_here { - // A tag pointing left, filled, with white text. - let tip = h * 0.45; - c.push_str(&format!( - "{}{}{} w 1 j\n{} {} m {} {} l {} {} l {} {} l {} {} l h B\n", - rg(col), + let turned = |mut stream: Stream| { + if turn != 0 { + let m = pdfcraft_model::view_matrix_for(turn, rect); + stream.dict.set(b"Matrix".to_vec(), Object::Array(m.iter().copied().map(Object::Real).collect())); + } + stream + }; + let [x0, y0, x1, y1] = draw; + let (w, h) = (x1 - x0, y1 - y0); + let lw = w.min(h) * 0.12; + match name { + b"PCCheck" => c.push_str(&format!( + "{}{} w 1 J 1 j\n{} {} m {} {} l {} {} l S\n", rg_stroke(col), n(lw), - n(x0 + inset), + n(x0 + w * 0.15), + n(y0 + h * 0.5), + n(x0 + w * 0.4), + n(y0 + h * 0.2), + n(x0 + w * 0.88), + n(y0 + h * 0.85) + )), + b"PCCross" => c.push_str(&format!( + "{}{} w 1 J\n{} {} m {} {} l {} {} m {} {} l S\n", + rg_stroke(col), + n(lw), + n(x0 + w * 0.18), + n(y0 + h * 0.18), + n(x1 - w * 0.18), + n(y1 - h * 0.18), + n(x0 + w * 0.18), + n(y1 - h * 0.18), + n(x1 - w * 0.18), + n(y0 + h * 0.18) + )), + b"PCDot" => { + let r = w.min(h) * 0.3; + c.push_str(&rg(col)); + c.push_str(&ellipse(x0 + w / 2.0 - r, y0 + h / 2.0 - r, x0 + w / 2.0 + r, y0 + h / 2.0 + r)); + c.push_str("f\n"); + } + b"PCLine" => c.push_str(&format!( + "{}{} w 1 J\n{} {} m {} {} l S\n", + rg_stroke(col), + n(h.clamp(0.5, 2.0)), + n(x0), n(y0 + h / 2.0), - n(x0 + tip), - n(y1 - inset), - n(x1 - inset), - n(y1 - inset), - n(x1 - inset), - n(y0 + inset), - n(x0 + tip), - n(y0 + inset) - )); + n(x1), + n(y0 + h / 2.0) + )), + other => { + // Only stamps PdfCraft made: others keep their own artwork. + if !matches!(d.get(b"PCStamp"), Some(Object::Bool(true))) { + return None; + } + let kind = crate::StampKind::from_name(other)?; + let by = d.get(b"PCByLine").and_then(|o| o.as_string()).map(PdfString::to_text); + let sign_here = kind.group() == crate::StampGroup::SignHere; + return Some(turned(stamp(kind.label(), sign_here, draw, stroke.unwrap_or(kind.color()), by.as_deref(), opacity, res))); + } + } + if turn != 0 { + return Some(turned(form(draw, c.as_bytes(), res))); + } + Some(form(rect, c.as_bytes(), res)) +} + +fn draw_free_text(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing) -> Option { + let d = context.dict; + let rect = context.rect; + let stroke = context.stroke; + let mut c = std::mem::take(&mut drawing.content); + let mut res = std::mem::take(&mut drawing.resources); + let _tail = std::mem::take(&mut drawing.tail); + let (text_color, size) = parse_da(d); + let bg = stroke; + let bw = if d.contains(b"BS") || d.contains(b"Border") { border_width(d) } else { 0.0 }; + // A callout: the leader line (arrowhead at its first point), and the text box inside + // `/Rect` by `/RD`. + // Text added to a turned page is drawn upright in its displayed box, which the form + // `/Matrix` then maps into `/Rect` (as for stamps). Callouts keep user-space axes. + let turn = if d.contains(b"CL") { 0 } else { text_turn(d) }; + let (rect, matrix) = if turn == 0 { + (rect, None) } else { - // A rounded frame with a light tint. - let r = (h * 0.2).min(8.0); - let k = 0.552_284_75 * r; - let (a0, b0, a1, b1) = (x0 + inset, y0 + inset, x1 - inset, y1 - inset); - let tint = col.map(|v| 1.0 - (1.0 - v) * 0.12); + let (rw, rh) = (rect[2] - rect[0], rect[3] - rect[1]); + let (dw, dh) = if matches!(turn, 90 | 270) { (rh, rw) } else { (rw, rh) }; + if dw < 1.0 || dh < 1.0 { + return None; + } + ([0.0, 0.0, dw, dh], Some(pdfcraft_model::view_matrix_for(turn, rect))) + }; + let full = rect; + let mut rect = rect; + if d.contains(b"CL") { + let cl = nums(d, b"CL").filter(|l| l.len() == 4 || l.len() == 6)?; + let rd = nums(d, b"RD").filter(|r| r.len() == 4 && r.iter().all(|x| *x >= 0.0))?; + rect = [full[0] + rd[0], full[1] + rd[1], full[2] - rd[2], full[3] - rd[3]]; + if rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 { + return None; + } + let end = match d.get(b"LE") { + None => b"None".to_vec(), + Some(o) => o.as_name()?.to_vec(), + }; + if !known_ending(&end) { + return None; + } + let lw = bw.max(0.5); + let pts: Vec<(f64, f64)> = cl.as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect(); + if pts.len() < 2 || pts.iter().any(|p| !p.0.is_finite() || !p.1.is_finite()) { + return None; + } + c.push_str(&format!("{}{}{} w 1 J 1 j\n", rg_stroke(text_color), rg(bg.unwrap_or([1.0; 3])), n(lw))); + for (i, p) in pts.iter().enumerate() { + c.push_str(&format!("{} {} {}\n", n(p.0), n(p.1), if i == 0 { "m" } else { "l" })); + } + c.push_str("S\n"); + if !line_end(&mut c, &end, pts[0], pts[1], lw) { + return None; + } + } + if let Some(bg) = bg { + c.push_str(&format!("{}{} {} {} {} re f\n", rg(bg), n(rect[0]), n(rect[1]), n(rect[2] - rect[0]), n(rect[3] - rect[1]))); + } + if bw > 0.0 { + let h = bw / 2.0; c.push_str(&format!( - "{}{}{} w\n{} {} m {} {} l {} {} {} {} {} {} c {} {} l {} {} {} {} {} {} c {} {} l {} {} {} {} {} {} c {} {} l {} {} {} {} {} {} c h B\n", - rg(tint), - rg_stroke(col), - n(lw), - n(a0 + r), - n(b0), - n(a1 - r), - n(b0), - n(a1 - r + k), - n(b0), - n(a1), - n(b0 + r - k), - n(a1), - n(b0 + r), - n(a1), - n(b1 - r), - n(a1), - n(b1 - r + k), - n(a1 - r + k), - n(b1), - n(a1 - r), - n(b1), - n(a0 + r), - n(b1), - n(a0 + r - k), - n(b1), - n(a0), - n(b1 - r + k), - n(a0), - n(b1 - r), - n(a0), - n(b0 + r), - n(a0), - n(b0 + r - k), - n(a0 + r - k), - n(b0), - n(a0 + r), - n(b0) + "{}{} w\n{}{} {} {} {} re S\n[] 0 d\n", + rg_stroke(text_color), + n(bw), + dash(d), + n(rect[0] + h), + n(rect[1] + h), + n(rect[2] - rect[0] - bw), + n(rect[3] - rect[1] - bw) )); } - let (title_h, by_h) = if by.is_some() { (h * 0.5, h * 0.22) } else { (h * 0.52, 0.0) }; - let text_area = if sign_here { (x0 + h * 0.45, x1 - lw * 2.0) } else { (x0 + lw * 2.0, x1 - lw * 2.0) }; - let mut size = title_h; - let tw = text_width(label, size) * 1.12; - if tw > text_area.1 - text_area.0 - 4.0 { - size *= (text_area.1 - text_area.0 - 4.0) / tw; - } - let tw = text_width(label, size) * 1.12; - let text_col = if sign_here { [1.0, 1.0, 1.0] } else { col }; - let ty = if by.is_some() { y0 + h * 0.48 } else { y0 + (h - size * 0.72) / 2.0 }; + let text = d.get(b"Contents").and_then(|o| o.as_string()).map(PdfString::to_text).unwrap_or_default(); + let pad = 2.0 + bw; + let width = (rect[2] - rect[0] - 2.0 * pad).max(1.0); + let q = d.int(b"Q").unwrap_or(0); + c.push_str(&format!( + "{} {} {} {} re W n\nBT\n/Helv {} Tf\n{}", + n(rect[0]), + n(rect[1]), + n(rect[2] - rect[0]), + n(rect[3] - rect[1]), + n(size), + rg(text_color) + )); let mut out = c.into_bytes(); - out.extend( - format!("BT\n{}/HelvB {} Tf\n1 0 0 1 {} {} Tm ", rg(text_col), n(size), n(text_area.0 + (text_area.1 - text_area.0 - tw) / 2.0), n(ty)) - .bytes(), - ); - out.extend(literal(&win_ansi(label))); - out.extend_from_slice(b" Tj\n"); - if let Some(b) = by { - let mut s = by_h; - let bw = text_width(b, s); - if bw > text_area.1 - text_area.0 - 4.0 { - s *= (text_area.1 - text_area.0 - 4.0) / bw; - } - let bw = text_width(b, s); - out.extend( - format!("/Helv {} Tf\n1 0 0 1 {} {} Tm ", n(s), n(text_area.0 + (text_area.1 - text_area.0 - bw) / 2.0), n(y0 + h * 0.18)).bytes(), - ); - out.extend(literal(&win_ansi(b))); + let mut y = rect[3] - pad - size * 0.9; + for line in wrap(&text, size, width) { + if y < rect[1] - size { + break; + } + let lw = text_width(&line, size); + let x = match q { + 1 => rect[0] + pad + (width - lw) / 2.0, + 2 => rect[2] - pad - lw, + _ => rect[0] + pad, + }; + out.extend(format!("1 0 0 1 {} {} Tm ", n(x), n(y)).bytes()); + out.extend(literal(&win_ansi(&line))); out.extend_from_slice(b" Tj\n"); + y -= size * 1.2; } out.extend_from_slice(b"ET\n"); - let font = |base: &str| { - let mut f = Dict::new(); - f.set(b"Type".to_vec(), Object::name("Font")); - f.set(b"Subtype".to_vec(), Object::name("Type1")); - f.set(b"BaseFont".to_vec(), Object::name(base)); - f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); - Object::Dict(f) - }; - let mut fonts = Dict::new(); - fonts.set(b"HelvB".to_vec(), font("Helvetica-Bold")); - fonts.set(b"Helv".to_vec(), font("Helvetica")); - res.set(b"Font".to_vec(), Object::Dict(fonts)); - form(rect, &out, res) -} - -/// An ellipse inscribed in a rectangle, as four Bézier arcs. -fn ellipse(x0: f64, y0: f64, x1: f64, y1: f64) -> String { - let k = 0.552_284_75; - let (cx, cy, rx, ry) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0, (x1 - x0) / 2.0, (y1 - y0) / 2.0); - let (ox, oy) = (rx * k, ry * k); - let mut s = format!("{} {} m\n", n(cx + rx), n(cy)); - for [a, b, c, d, e, f] in [ - [cx + rx, cy + oy, cx + ox, cy + ry, cx, cy + ry], - [cx - ox, cy + ry, cx - rx, cy + oy, cx - rx, cy], - [cx - rx, cy - oy, cx - ox, cy - ry, cx, cy - ry], - [cx + ox, cy - ry, cx + rx, cy - oy, cx + rx, cy], - ] { - s.push_str(&format!("{} {} {} {} {} {} c\n", n(a), n(b), n(c), n(d), n(e), n(f))); + res.set(b"Font".to_vec(), helvetica_resource()); + let mut stream = form(full, &out, res); + if let Some(m) = matrix { + stream.dict.set(b"Matrix".to_vec(), Object::Array(m.iter().copied().map(Object::Real).collect())); } - s.push_str("h\n"); - s + Some(stream) } -/// PdfCraft's note icons, drawn in a 20 × 20 box: a speech bubble for `/Comment`, a page -/// with a folded corner for everything else, both filled with the note colour. -/// File attachment icons in a 20 × 20 box: PdfCraft's own drawings. -fn attach_icon(name: &str, col: Rgb) -> String { - let mut s = format!("{}{}1.2 w 1 j 1 J\n", rg(col), rg_stroke(col)); - match name { - "Paperclip" => s.push_str("8 4 m 8 15 l 8 18 13 18 13 15 c 13 6 l 13 3 10 3 10 6 c 10 14 l S\n"), - "Graph" => s.push_str("3 3 m 3 17 l 3 3 m 17 3 l S\n5 3 3 6 re 9 3 3 10 re 13 3 3 13 re f\n"), - "Tag" => s.push_str("3 10 m 8 15 l 17 15 l 17 5 l 8 5 l h S\n7.3 10 m 7.3 10.7 6.7 11.3 6 11.3 c 5.3 11.3 4.7 10.7 4.7 10 c 4.7 9.3 5.3 8.7 6 8.7 c 6.7 8.7 7.3 9.3 7.3 10 c f\n"), - _ => { - // Push pin: a round head on a short needle. - s.push_str("10 2 m 10 9 l S\n6 9 m 14 9 l 14 11 l 12 12 l 12 16 l 13.5 17 l 13.5 18.5 l 6.5 18.5 l 6.5 17 l 8 16 l 8 12 l 6 11 l h f\n"); - } - } - s -} - -/// A closed circle path (four Bézier quarters) centred on (`x`, `y`). -fn circle_path(x: f64, y: f64, r: f64) -> String { - let k = r * 0.5523; - let p = |v: f64| n(v); - format!( - "{} {} m {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c h\n", - p(x + r), - p(y), - p(x + r), - p(y + k), - p(x + k), - p(y + r), - p(x), - p(y + r), - p(x - k), - p(y + r), - p(x - r), - p(y + k), - p(x - r), - p(y), - p(x - r), - p(y - k), - p(x - k), - p(y - r), - p(x), - p(y - r), - p(x + k), - p(y - r), - p(x + r), - p(y - k), - p(x + r), - p(y) - ) -} - -/// A note's icon in a 20 × 20 box: filled in `col`, outlined in dark grey. Each name of -/// [`crate::NoteIcon`] has its own shape, so the icon a note names is the one it shows (ISO -/// 32000-2 §12.5.6.4, Table 175); a name PdfCraft doesn't know draws as `Note`. -fn note_icon(name: &str, col: Rgb) -> String { - let mut s = format!("{}0.25 0.25 0.25 RG 0.8 w 1 j 1 J\n", rg(col)); - match name { - "Comment" => { - s.push_str("3 18.5 m 17 18.5 l 18.5 18.5 18.5 17 18.5 17 c 18.5 8 l 18.5 6.5 17 6.5 17 6.5 c 9.5 6.5 l 5 2 l 5.5 6.5 l 3 6.5 l 1.5 6.5 1.5 8 1.5 8 c 1.5 17 l 1.5 18.5 3 18.5 3 18.5 c h B\n"); - s.push_str("4.5 15 m 15.5 15 l 4.5 12.5 m 15.5 12.5 l 4.5 10 m 11.5 10 l S\n"); - } - // A key lying flat: a round bow with a hole, a shaft and two bits. - "Key" => { - s.push_str(&circle_path(5.5, 10.0, 4.5)); - s.push_str(&circle_path(5.0, 10.0, 1.5)); - s.push_str( - "9.5 11.2 m 18.5 11.2 l 18.5 8.8 l 17 8.8 l 17 6 l 15 6 l 15 8.8 l 13.5 8.8 l 13.5 6.8 l 11.5 6.8 l 11.5 8.8 l 9.5 8.8 l h B*\n", - ); - } - // A disc with a question mark. - "Help" => { - s.push_str(&circle_path(10.0, 10.0, 8.5)); - s.push_str("B\n1.6 w 7 12.8 m 7 15.6 13 15.6 13 12.8 c 13 10.6 10 10.8 10 8.2 c S\n10 5 m 10 5.1 l S\n"); - } - // An insertion caret. - "Insert" => s.push_str("1.5 3 m 10 18 l 18.5 3 l 14.5 3 l 10 11 l 5.5 3 l h B\n"), - // A caret over a bar: a new paragraph goes here. - "NewParagraph" => s.push_str("3.5 9 m 10 18.5 l 16.5 9 l h B\n3 2 14 4 re B\n"), - // A pilcrow: a filled bowl and two stems. - "Paragraph" => { - s.push_str("11 18.5 m 7 18.5 4 16.3 4 13.2 c 4 10.1 7 8 11 8 c h B\n"); - s.push_str("11 18.5 m 16 18.5 l 16 1.5 l 14 1.5 l 14 16.5 l 13 16.5 l 13 1.5 l 11 1.5 l h B\n"); - } - // A tick. - "Check" => s.push_str("1.5 10.5 m 4.5 13.5 l 8 9.5 l 16 18.5 l 18.5 15.5 l 8 3.5 l h B\n"), - // A ring. - "Circle" => { - s.push_str(&circle_path(10.0, 10.0, 8.5)); - s.push_str(&circle_path(10.0, 10.0, 5.0)); - s.push_str("B*\n"); - } - // A thick X. - "Cross" => s.push_str("4 1.5 m 10 7.5 l 16 1.5 l 18.5 4 l 12.5 10 l 18.5 16 l 16 18.5 l 10 12.5 l 4 18.5 l 1.5 16 l 7.5 10 l 1.5 4 l h B\n"), - // A ring with sighting lines through it. - "CrossHairs" => { - s.push_str(&circle_path(10.0, 10.0, 7.0)); - s.push_str(&circle_path(10.0, 10.0, 4.5)); - s.push_str("B*\n1.2 w 10 0.5 m 10 19.5 l 0.5 10 m 19.5 10 l S\n"); - } - // A five-pointed star. - "Star" => { - let points: Vec = (0..10) - .map(|i| { - let r = if i % 2 == 0 { 9.5 } else { 3.9 }; - let a = std::f64::consts::FRAC_PI_2 + f64::from(i) * std::f64::consts::PI / 5.0; - format!("{} {}", n(10.0 + r * a.cos()), n(9.5 + r * a.sin())) - }) - .collect(); - s.push_str(&format!("{} m {} l h B\n", points[0], points[1..].join(" l "))); - } - // Block arrows. - "RightArrow" => s.push_str("1.5 7 m 11 7 l 11 2.5 l 18.5 10 l 11 17.5 l 11 13 l 1.5 13 l h B\n"), - "UpArrow" => s.push_str("7 1.5 m 7 11 l 2.5 11 l 10 18.5 l 17.5 11 l 13 11 l 13 1.5 l h B\n"), - _ => { - s.push_str("3 19 m 13 19 l 17 15 l 17 1 l 3 1 l h B\n13 19 m 13 15 l 17 15 l S\n"); - s.push_str("5.5 12 m 14.5 12 l 5.5 9 m 14.5 9 l 5.5 6 m 11.5 6 l S\n"); - } - } - s +fn draw_icon(context: &AppearanceContext<'_>, name: &str, fallback: Rgb, mut drawing: AppearanceDrawing) -> Option { + let d = context.dict; + let col = context.stroke.unwrap_or(fallback); + let icon = d.name(b"Name").map(|n| String::from_utf8_lossy(n).into_owned()).unwrap_or_else(|| name.into()); + let content = if name == "Note" { note_icon(&icon, col) } else { attach_icon(&icon, col) }; + drawing.content.push_str(&content); + Some(form([0.0, 0.0, NOTE_SIZE, NOTE_SIZE], drawing.content.as_bytes(), drawing.resources)) } diff --git a/crates/annot/src/appearance/symbols.rs b/crates/annot/src/appearance/symbols.rs new file mode 100644 index 0000000000..3b33662524 --- /dev/null +++ b/crates/annot/src/appearance/symbols.rs @@ -0,0 +1,534 @@ +use super::{Dict, Object, PdfString, Rgb, Stream, color, ext_gstate, form, literal, n, nums, rg, rg_stroke, text_width, win_ansi}; + +/// The label and colour of a standard rubber stamp name (ISO 32000-2 Table 184), for a stamp +/// another program wrote without an appearance. Names are matched byte for byte, as PDF names +/// are compared (§7.3.5). Eight are stamps of PdfCraft's palette; the other six take a label of +/// their own and the palette's colour for their meaning. +fn standard_stamp(name: &[u8]) -> Option<(&'static str, Rgb)> { + use crate::StampKind::*; + let palette = [Approved, NotApproved, NotForPublicRelease, Confidential, Final, ForComment, Draft, ForPublicRelease]; + if let Some(kind) = palette.into_iter().find(|k| k.name().as_bytes() == name) { + return Some((kind.label(), kind.color())); + } + let (label, like) = match name { + b"Experimental" => ("EXPERIMENTAL", Draft), + b"AsIs" => ("AS IS", Draft), + b"Expired" => ("EXPIRED", NotApproved), + b"Sold" => ("SOLD", Approved), + b"Departmental" => ("DEPARTMENTAL", Draft), + b"TopSecret" => ("TOP SECRET", Confidential), + _ => return None, + }; + Some((label, like.color())) +} + +/// The neutral grey of a stamp whose name has no drawing. +const PLACEHOLDER_GREY: Rgb = [0.45, 0.45, 0.45]; + +/// The display appearance of a rubber stamp another program wrote without one: the stamp its +/// `/Name` names (Draft when there is none, Table 184's default) in its `/C` colour or the +/// stamp's own, or for any other name a thin grey frame with both diagonals, so the stamp stays +/// visible and can be selected. The frame is grey whatever the `/C`: it stands in for a stamp +/// that cannot be drawn, so it takes none of that stamp's colour. The name is never drawn as text. +/// +/// For display only ([`crate::with_missing_appearances`]): [`build`] declines these stamps, so +/// edits (restyling, flattening) never write this drawing into the file. `None` for any other +/// annotation, one of PdfCraft's own stamps, a dictionary with an `/AP` (a stamp's own artwork is +/// never replaced; the display copy passes the dictionary without an `/AP` that has no normal +/// appearance), or a `/Rect` that isn't finite or is under a point wide or high. +pub(crate) fn display_stamp(d: &Dict) -> Option { + let own = matches!(d.get(b"PCStamp"), Some(Object::Bool(true))) || d.contains(b"PCPicture") || d.contains(b"PCOutline"); + if d.name(b"Subtype") != Some(b"Stamp") || own || d.contains(b"AP") { + return None; + } + let rect = nums(d, b"Rect")?; + let [a, b, c, e] = rect.as_slice() else { return None }; + let rect = [a.min(*c), b.min(*e), a.max(*c), b.max(*e)]; + if !rect.iter().all(|v| v.is_finite()) || rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 { + return None; + } + let opacity = d.get(b"CA").and_then(|o| o.as_f64()).unwrap_or(1.0).clamp(0.0, 1.0); + // A malformed `/C` falls back to the stamp's own colour rather than hiding the stamp. + let stroke = color(d, b"C").flatten(); + let mut res = Dict::new(); + if let Some(gs) = ext_gstate(opacity, false) { + res.set(b"ExtGState".to_vec(), Object::Dict(gs)); + } + // A stamp whose intent (`/IT`, PDF 2.0) is an image or a snapshot has no `/Name` to default. + let stamp_intent = d.name(b"IT").is_none_or(|it| it == b"Stamp"); + let name = d.name(b"Name").or(stamp_intent.then_some(b"Draft".as_slice())); + if let Some((label, col)) = name.and_then(standard_stamp) { + return Some(stamp(label, false, rect, stroke.unwrap_or(col), None, opacity, res)); + } + let [x0, y0, x1, y1] = rect; + let (a0, b0, a1, b1) = (x0 + 0.5, y0 + 0.5, x1 - 0.5, y1 - 0.5); + let mut c = String::new(); + if opacity < 1.0 { + c.push_str("/GS0 gs\n"); + } + c.push_str(&format!( + "{}1 w\n{} {} {} {} re\n{} {} m {} {} l\n{} {} m {} {} l\nS\n", + // Grey whatever the `/C`: the placeholder is not the stamp, so it takes none of its colour. + rg_stroke(PLACEHOLDER_GREY), + n(a0), + n(b0), + n(a1 - a0), + n(b1 - b0), + n(a0), + n(b0), + n(a1), + n(b1), + n(a0), + n(b1), + n(a1), + n(b0) + )); + Some(form(rect, c.as_bytes(), res)) +} + +/// Size of a line's caption, as of a measurement's label. +pub(super) const CAPTION_SIZE: f64 = 10.0; + +/// Characters of a line's caption drawn at most, as for a measurement's label. +const MAX_CAPTION_CHARS: usize = 256; + +/// Leader lengths and caption offsets (Table 178) are held within this many points (200 inches, +/// the largest page side ISO 32000-2 Annex C allows), so an absurd value can't overflow. +const MAX_LINE_EXTENT: f64 = 14_400.0; + +/// A Table 178 length or offset: finite and within [`MAX_LINE_EXTENT`], 0 when absent or not one. +fn line_length(v: Option) -> f64 { + v.filter(|v| v.is_finite()).map_or(0.0, |v| v.clamp(-MAX_LINE_EXTENT, MAX_LINE_EXTENT)) +} + +/// The caption a Line with `/Cap true` shows (ISO 32000-2 Table 178): its `/Contents`, else the +/// plain text of its `/RC`, on one line and cut to [`MAX_CAPTION_CHARS`]. `None` without a +/// caption, and for PdfCraft's own measurements, whose label (`/PCMeasureValue`) is the caption. +pub(super) fn line_caption(d: &Dict) -> Option { + if !matches!(d.get(b"Cap"), Some(Object::Bool(true))) || d.contains(b"PCMeasureValue") { + return None; + } + let text = |key: &[u8]| d.get(key).and_then(Object::as_string).map(PdfString::to_text); + let raw = text(b"Contents").filter(|t| !t.trim().is_empty()).or_else(|| text(b"RC").map(|rc| rich_plain(&rc)))?; + let words: Vec<&str> = raw.split_whitespace().collect(); + let caption: String = words.join(" ").chars().filter(|c| !c.is_control()).take(MAX_CAPTION_CHARS).collect(); + (!caption.is_empty()).then_some(caption) +} + +/// The text of rich text (§12.7.4.3, XHTML): its markup dropped, the five XML entities and +/// numeric character references read, block ends read as spaces. +fn rich_plain(rc: &str) -> String { + let mut out = String::new(); + let mut tag: Option = None; + // Rich text past this is not caption material; it bounds the work. + for ch in rc.chars().take(MAX_CAPTION_CHARS * 64) { + match (&mut tag, ch) { + (None, '<') => tag = Some(String::new()), + (Some(name), '>') => { + let name = + name.trim_start_matches('/').split(|c: char| c.is_whitespace() || c == '/').next().unwrap_or_default().to_ascii_lowercase(); + if matches!(name.as_str(), "p" | "br" | "div" | "li") { + out.push(' '); + } + tag = None; + } + (Some(name), c) => { + if name.len() < 16 { + name.push(c); + } + } + (None, c) => out.push(c), + } + } + let mut text = String::new(); + let mut rest = out.as_str(); + while let Some(at) = rest.find('&') { + text.push_str(rest.get(..at).unwrap_or_default()); + let after = rest.get(at + 1..).unwrap_or_default(); + let entity = after.find(';').filter(|&end| end <= 10).and_then(|end| Some((after.get(..end)?, end))); + let decoded = entity.and_then(|(name, end)| { + let c = match name { + "amp" => '&', + "lt" => '<', + "gt" => '>', + "quot" => '"', + "apos" => '\'', + _ => { + let num = name.strip_prefix('#')?; + let code = match num.strip_prefix(['x', 'X']) { + Some(hex) => u32::from_str_radix(hex, 16).ok()?, + None => num.parse().ok()?, + }; + char::from_u32(code)? + } + }; + Some((c, end)) + }); + match decoded { + Some((c, end)) => { + text.push(c); + rest = after.get(end + 1..).unwrap_or_default(); + } + None => { + text.push('&'); + rest = after; + } + } + } + text.push_str(rest); + text +} + +/// Where a Line annotation's parts go (ISO 32000-2 §12.5.6.7, Table 178). +#[derive(Debug, Default)] +pub(super) struct LineLayout { + /// The line itself, start to end: `/L`, or `/L` moved out by its leader lines. + pub(super) line: [(f64, f64); 2], + /// Strokes along the line: the whole line, or the two pieces either side of an inline caption. + pub(super) strokes: Vec<[(f64, f64); 2]>, + /// The leader lines, from `/L` (past the `/LLO` gap) to the line and on by `/LLE`. + pub(super) leaders: Vec<[(f64, f64); 2]>, + /// A short stroke from the line to a caption `/CO` moved off it. + pub(super) tick: Option<[(f64, f64); 2]>, + /// The caption and its text matrix. + pub(super) caption: Option<(String, [f64; 6])>, +} + +/// Lay out a Line annotation from `/L` = `[a b]`, line width `w`, and its leader and caption +/// entries. +/// +/// Leader lines: a positive `/LL` puts the line on the left of travel from `a` to `b` (above `/L` +/// for a line drawn left to right), so the leader lines, followed from the line to `/L`, turn +/// clockwise from the direction of travel; a negative one puts it on the right. The line sits +/// `|LL| + LLO` from `/L`. `/LLE` and `/LLO` are non-negative: a negative one counts as 0. +/// +/// Caption (`/Cap true`): centred on the line's midpoint and turned with it so it reads left to +/// right (bottom to top for a vertical line). `/CP /Inline` (the default) centres it on the line, +/// which is broken around it; `/Top` sets it on top of the unbroken line. `/CO [h v]` moves it `h` +/// along the line and `v` across it (positive: up in reading direction); a caption moved off an +/// inline line gets a tick from the line to it. +/// +/// A zero-length or overflowing `/L` has no direction: it gets neither leader lines nor caption. +/// Every point is finite; if anything derived from the entries is not, they are left out. +pub(super) fn line_layout(d: &Dict, [a, b]: [(f64, f64); 2], w: f64) -> LineLayout { + let plain = LineLayout { line: [a, b], strokes: vec![[a, b]], ..LineLayout::default() }; + let (dx, dy) = (b.0 - a.0, b.1 - a.1); + let len = dx.hypot(dy); + if !len.is_finite() || len <= 0.0 { + return plain; + } + let (ux, uy) = (dx / len, dy / len); + let mut out = LineLayout { line: [a, b], ..LineLayout::default() }; + let ll = line_length(d.get(b"LL").and_then(Object::as_f64)); + if ll != 0.0 { + // The unit normal from `/L` towards the line: left of travel for a positive /LL. + let s = ll.signum(); + let (nx, ny) = (-uy * s, ux * s); + let llo = line_length(d.get(b"LLO").and_then(Object::as_f64)).max(0.0); + let lle = line_length(d.get(b"LLE").and_then(Object::as_f64)).max(0.0); + let at = |p: (f64, f64), k: f64| (p.0 + nx * k, p.1 + ny * k); + let off = ll.abs() + llo; + out.line = [at(a, off), at(b, off)]; + out.leaders = vec![[at(a, llo), at(a, off + lle)], [at(b, llo), at(b, off + lle)]]; + } + let [p, _] = out.line; + let along = |t: f64| (p.0 + ux * t, p.1 + uy * t); + let mut gap = None; + if let Some(text) = line_caption(d) { + let size = CAPTION_SIZE; + let width = text_width(&text, size); + // Reading direction, and "up" across it. + let (tx, ty) = if ux < 0.0 || (ux == 0.0 && uy < 0.0) { (-ux, -uy) } else { (ux, uy) }; + let (vx, vy) = (-ty, tx); + let co = nums(d, b"CO").filter(|c| c.len() == 2).unwrap_or_default(); + let h = line_length(co.first().copied()); + let v = line_length(co.get(1).copied()); + let top = d.name(b"CP") == Some(b"Top"); + // The caption's centre, as a distance along the line from `p`. + let centre = len / 2.0 + h * (tx * ux + ty * uy); + let mid = along(centre); + // Baseline across the line: on top of it, or with the text centred on it. + let base = if top { w.max(0.0) / 2.0 + 2.0 + v } else { v - size * 0.35 }; + let origin = (mid.0 - tx * width / 2.0 + vx * base, mid.1 - ty * width / 2.0 + vy * base); + out.caption = Some((text, [tx, ty, -ty, tx, origin.0, origin.1])); + if !top && v.abs() < size / 2.0 { + let half = width / 2.0 + 2.0; + gap = Some(((centre - half).clamp(0.0, len), (centre + half).clamp(0.0, len))); + } else if !top { + let reach = v - v.signum() * size * 0.4; + out.tick = Some([mid, (mid.0 + vx * reach, mid.1 + vy * reach)]); + } + } + out.strokes = match gap { + Some((g0, g1)) => [(0.0, g0), (g1, len)].into_iter().filter(|(s, e)| e > s).map(|(s, e)| [along(s), along(e)]).collect(), + None => vec![out.line], + }; + let finite = |q: &(f64, f64)| q.0.is_finite() && q.1.is_finite(); + let all_finite = + out.line.iter().chain(out.strokes.iter().flatten()).chain(out.leaders.iter().flatten()).chain(out.tick.iter().flatten()).all(finite) + && out.caption.as_ref().is_none_or(|(_, m)| m.iter().all(|x| x.is_finite())); + if all_finite { out } else { plain } +} + +/// A rubber stamp: a rounded frame (a pointed tag for sign-here stamps) with the label in bold +/// capitals, and the dynamic stamps' "By … at …" line. +pub(super) fn stamp(label: &str, sign_here: bool, rect: [f64; 4], col: Rgb, by: Option<&str>, opacity: f64, mut res: Dict) -> Stream { + let [x0, y0, x1, y1] = rect; + let h = y1 - y0; + let mut c = String::new(); + if opacity < 1.0 { + c.push_str("/GS0 gs\n"); + } + let lw = (h * 0.07).clamp(1.0, 3.0); + let inset = lw / 2.0 + 0.5; + if sign_here { + // A tag pointing left, filled, with white text. + let tip = h * 0.45; + c.push_str(&format!( + "{}{}{} w 1 j\n{} {} m {} {} l {} {} l {} {} l {} {} l h B\n", + rg(col), + rg_stroke(col), + n(lw), + n(x0 + inset), + n(y0 + h / 2.0), + n(x0 + tip), + n(y1 - inset), + n(x1 - inset), + n(y1 - inset), + n(x1 - inset), + n(y0 + inset), + n(x0 + tip), + n(y0 + inset) + )); + } else { + // A rounded frame with a light tint. + let r = (h * 0.2).min(8.0); + let k = 0.552_284_75 * r; + let (a0, b0, a1, b1) = (x0 + inset, y0 + inset, x1 - inset, y1 - inset); + let tint = col.map(|v| 1.0 - (1.0 - v) * 0.12); + c.push_str(&format!( + "{}{}{} w\n{} {} m {} {} l {} {} {} {} {} {} c {} {} l {} {} {} {} {} {} c {} {} l {} {} {} {} {} {} c {} {} l {} {} {} {} {} {} c h B\n", + rg(tint), + rg_stroke(col), + n(lw), + n(a0 + r), + n(b0), + n(a1 - r), + n(b0), + n(a1 - r + k), + n(b0), + n(a1), + n(b0 + r - k), + n(a1), + n(b0 + r), + n(a1), + n(b1 - r), + n(a1), + n(b1 - r + k), + n(a1 - r + k), + n(b1), + n(a1 - r), + n(b1), + n(a0 + r), + n(b1), + n(a0 + r - k), + n(b1), + n(a0), + n(b1 - r + k), + n(a0), + n(b1 - r), + n(a0), + n(b0 + r), + n(a0), + n(b0 + r - k), + n(a0 + r - k), + n(b0), + n(a0 + r), + n(b0) + )); + } + let (title_h, by_h) = if by.is_some() { (h * 0.5, h * 0.22) } else { (h * 0.52, 0.0) }; + let text_area = if sign_here { (x0 + h * 0.45, x1 - lw * 2.0) } else { (x0 + lw * 2.0, x1 - lw * 2.0) }; + let mut size = title_h; + let tw = text_width(label, size) * 1.12; + if tw > text_area.1 - text_area.0 - 4.0 { + size *= (text_area.1 - text_area.0 - 4.0) / tw; + } + let tw = text_width(label, size) * 1.12; + let text_col = if sign_here { [1.0, 1.0, 1.0] } else { col }; + let ty = if by.is_some() { y0 + h * 0.48 } else { y0 + (h - size * 0.72) / 2.0 }; + let mut out = c.into_bytes(); + out.extend( + format!("BT\n{}/HelvB {} Tf\n1 0 0 1 {} {} Tm ", rg(text_col), n(size), n(text_area.0 + (text_area.1 - text_area.0 - tw) / 2.0), n(ty)) + .bytes(), + ); + out.extend(literal(&win_ansi(label))); + out.extend_from_slice(b" Tj\n"); + if let Some(b) = by { + let mut s = by_h; + let bw = text_width(b, s); + if bw > text_area.1 - text_area.0 - 4.0 { + s *= (text_area.1 - text_area.0 - 4.0) / bw; + } + let bw = text_width(b, s); + out.extend( + format!("/Helv {} Tf\n1 0 0 1 {} {} Tm ", n(s), n(text_area.0 + (text_area.1 - text_area.0 - bw) / 2.0), n(y0 + h * 0.18)).bytes(), + ); + out.extend(literal(&win_ansi(b))); + out.extend_from_slice(b" Tj\n"); + } + out.extend_from_slice(b"ET\n"); + let font = |base: &str| { + let mut f = Dict::new(); + f.set(b"Type".to_vec(), Object::name("Font")); + f.set(b"Subtype".to_vec(), Object::name("Type1")); + f.set(b"BaseFont".to_vec(), Object::name(base)); + f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); + Object::Dict(f) + }; + let mut fonts = Dict::new(); + fonts.set(b"HelvB".to_vec(), font("Helvetica-Bold")); + fonts.set(b"Helv".to_vec(), font("Helvetica")); + res.set(b"Font".to_vec(), Object::Dict(fonts)); + form(rect, &out, res) +} + +/// An ellipse inscribed in a rectangle, as four Bézier arcs. +pub(super) fn ellipse(x0: f64, y0: f64, x1: f64, y1: f64) -> String { + let k = 0.552_284_75; + let (cx, cy, rx, ry) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0, (x1 - x0) / 2.0, (y1 - y0) / 2.0); + let (ox, oy) = (rx * k, ry * k); + let mut s = format!("{} {} m\n", n(cx + rx), n(cy)); + for [a, b, c, d, e, f] in [ + [cx + rx, cy + oy, cx + ox, cy + ry, cx, cy + ry], + [cx - ox, cy + ry, cx - rx, cy + oy, cx - rx, cy], + [cx - rx, cy - oy, cx - ox, cy - ry, cx, cy - ry], + [cx + ox, cy - ry, cx + rx, cy - oy, cx + rx, cy], + ] { + s.push_str(&format!("{} {} {} {} {} {} c\n", n(a), n(b), n(c), n(d), n(e), n(f))); + } + s.push_str("h\n"); + s +} + +/// PdfCraft's note icons, drawn in a 20 × 20 box: a speech bubble for `/Comment`, a page +/// with a folded corner for everything else, both filled with the note colour. +/// File attachment icons in a 20 × 20 box: PdfCraft's own drawings. +pub(super) fn attach_icon(name: &str, col: Rgb) -> String { + let mut s = format!("{}{}1.2 w 1 j 1 J\n", rg(col), rg_stroke(col)); + match name { + "Paperclip" => s.push_str("8 4 m 8 15 l 8 18 13 18 13 15 c 13 6 l 13 3 10 3 10 6 c 10 14 l S\n"), + "Graph" => s.push_str("3 3 m 3 17 l 3 3 m 17 3 l S\n5 3 3 6 re 9 3 3 10 re 13 3 3 13 re f\n"), + "Tag" => s.push_str("3 10 m 8 15 l 17 15 l 17 5 l 8 5 l h S\n7.3 10 m 7.3 10.7 6.7 11.3 6 11.3 c 5.3 11.3 4.7 10.7 4.7 10 c 4.7 9.3 5.3 8.7 6 8.7 c 6.7 8.7 7.3 9.3 7.3 10 c f\n"), + _ => { + // Push pin: a round head on a short needle. + s.push_str("10 2 m 10 9 l S\n6 9 m 14 9 l 14 11 l 12 12 l 12 16 l 13.5 17 l 13.5 18.5 l 6.5 18.5 l 6.5 17 l 8 16 l 8 12 l 6 11 l h f\n"); + } + } + s +} + +/// A closed circle path (four Bézier quarters) centred on (`x`, `y`). +fn circle_path(x: f64, y: f64, r: f64) -> String { + let k = r * 0.5523; + let p = |v: f64| n(v); + format!( + "{} {} m {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c h\n", + p(x + r), + p(y), + p(x + r), + p(y + k), + p(x + k), + p(y + r), + p(x), + p(y + r), + p(x - k), + p(y + r), + p(x - r), + p(y + k), + p(x - r), + p(y), + p(x - r), + p(y - k), + p(x - k), + p(y - r), + p(x), + p(y - r), + p(x + k), + p(y - r), + p(x + r), + p(y - k), + p(x + r), + p(y) + ) +} + +/// A note's icon in a 20 × 20 box: filled in `col`, outlined in dark grey. Each name of +/// [`crate::NoteIcon`] has its own shape, so the icon a note names is the one it shows (ISO +/// 32000-2 §12.5.6.4, Table 175); a name PdfCraft doesn't know draws as `Note`. +pub(super) fn note_icon(name: &str, col: Rgb) -> String { + let mut s = format!("{}0.25 0.25 0.25 RG 0.8 w 1 j 1 J\n", rg(col)); + match name { + "Comment" => { + s.push_str("3 18.5 m 17 18.5 l 18.5 18.5 18.5 17 18.5 17 c 18.5 8 l 18.5 6.5 17 6.5 17 6.5 c 9.5 6.5 l 5 2 l 5.5 6.5 l 3 6.5 l 1.5 6.5 1.5 8 1.5 8 c 1.5 17 l 1.5 18.5 3 18.5 3 18.5 c h B\n"); + s.push_str("4.5 15 m 15.5 15 l 4.5 12.5 m 15.5 12.5 l 4.5 10 m 11.5 10 l S\n"); + } + // A key lying flat: a round bow with a hole, a shaft and two bits. + "Key" => { + s.push_str(&circle_path(5.5, 10.0, 4.5)); + s.push_str(&circle_path(5.0, 10.0, 1.5)); + s.push_str( + "9.5 11.2 m 18.5 11.2 l 18.5 8.8 l 17 8.8 l 17 6 l 15 6 l 15 8.8 l 13.5 8.8 l 13.5 6.8 l 11.5 6.8 l 11.5 8.8 l 9.5 8.8 l h B*\n", + ); + } + // A disc with a question mark. + "Help" => { + s.push_str(&circle_path(10.0, 10.0, 8.5)); + s.push_str("B\n1.6 w 7 12.8 m 7 15.6 13 15.6 13 12.8 c 13 10.6 10 10.8 10 8.2 c S\n10 5 m 10 5.1 l S\n"); + } + // An insertion caret. + "Insert" => s.push_str("1.5 3 m 10 18 l 18.5 3 l 14.5 3 l 10 11 l 5.5 3 l h B\n"), + // A caret over a bar: a new paragraph goes here. + "NewParagraph" => s.push_str("3.5 9 m 10 18.5 l 16.5 9 l h B\n3 2 14 4 re B\n"), + // A pilcrow: a filled bowl and two stems. + "Paragraph" => { + s.push_str("11 18.5 m 7 18.5 4 16.3 4 13.2 c 4 10.1 7 8 11 8 c h B\n"); + s.push_str("11 18.5 m 16 18.5 l 16 1.5 l 14 1.5 l 14 16.5 l 13 16.5 l 13 1.5 l 11 1.5 l h B\n"); + } + // A tick. + "Check" => s.push_str("1.5 10.5 m 4.5 13.5 l 8 9.5 l 16 18.5 l 18.5 15.5 l 8 3.5 l h B\n"), + // A ring. + "Circle" => { + s.push_str(&circle_path(10.0, 10.0, 8.5)); + s.push_str(&circle_path(10.0, 10.0, 5.0)); + s.push_str("B*\n"); + } + // A thick X. + "Cross" => s.push_str("4 1.5 m 10 7.5 l 16 1.5 l 18.5 4 l 12.5 10 l 18.5 16 l 16 18.5 l 10 12.5 l 4 18.5 l 1.5 16 l 7.5 10 l 1.5 4 l h B\n"), + // A ring with sighting lines through it. + "CrossHairs" => { + s.push_str(&circle_path(10.0, 10.0, 7.0)); + s.push_str(&circle_path(10.0, 10.0, 4.5)); + s.push_str("B*\n1.2 w 10 0.5 m 10 19.5 l 0.5 10 m 19.5 10 l S\n"); + } + // A five-pointed star. + "Star" => { + let points: Vec = (0..10) + .map(|i| { + let r = if i % 2 == 0 { 9.5 } else { 3.9 }; + let a = std::f64::consts::FRAC_PI_2 + f64::from(i) * std::f64::consts::PI / 5.0; + format!("{} {}", n(10.0 + r * a.cos()), n(9.5 + r * a.sin())) + }) + .collect(); + s.push_str(&format!("{} m {} l h B\n", points[0], points[1..].join(" l "))); + } + // Block arrows. + "RightArrow" => s.push_str("1.5 7 m 11 7 l 11 2.5 l 18.5 10 l 11 17.5 l 11 13 l 1.5 13 l h B\n"), + "UpArrow" => s.push_str("7 1.5 m 7 11 l 2.5 11 l 10 18.5 l 17.5 11 l 13 11 l 13 1.5 l h B\n"), + _ => { + s.push_str("3 19 m 13 19 l 17 15 l 17 1 l 3 1 l h B\n13 19 m 13 15 l 17 15 l S\n"); + s.push_str("5.5 12 m 14.5 12 l 5.5 9 m 14.5 9 l 5.5 6 m 11.5 6 l S\n"); + } + } + s +} diff --git a/crates/annot/src/create.rs b/crates/annot/src/create.rs new file mode 100644 index 0000000000..1904745e95 --- /dev/null +++ b/crates/annot/src/create.rs @@ -0,0 +1,493 @@ +use super::draw::draw_appearance; +use super::lookup::{annot_dict, annot_ref, annots, fill_sign_shape, page_ref, set_annots}; +use super::modify::{n, rich_text}; +use super::types::{ + FLAG_NO_ROTATE, FLAG_NO_ZOOM, FLAG_PRINT, LineEnding, Markup, Meta, NOTE_SIZE, NewAnnotation, OverlayLook, Rgb, Shape, StampGroup, Style, +}; +use super::{AnnotError, Dict, Document, ObjRef, Object, PdfString, appearance}; + +pub(crate) fn num_array(v: &[f64]) -> Object { + Object::Array(v.iter().map(|x| Object::Real(*x)).collect()) +} + +pub(crate) fn rgb(c: Rgb) -> Object { + num_array(&c.map(|x| x.clamp(0.0, 1.0))) +} + +pub(crate) fn finite(v: &[f64]) -> bool { + v.iter().all(|x| x.is_finite() && x.abs() < 1e7) +} + +pub(crate) fn normalize(r: [f64; 4]) -> [f64; 4] { + [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])] +} + +pub(crate) fn bounds(points: impl Iterator) -> Option<[f64; 4]> { + points.fold(None, |acc, [x, y]| match acc { + None => Some([x, y, x, y]), + Some([a, b, c, d]) => Some([a.min(x), b.min(y), c.max(x), d.max(y)]), + }) +} + +pub(crate) fn grow(r: [f64; 4], by: f64) -> [f64; 4] { + [r[0] - by, r[1] - by, r[2] + by, r[3] + by] +} + +/// Tight bounding box of ink strokes, including Bezier control points for strokes with three or +/// more points (matching [`appearance::smooth_segments`]). +pub(crate) fn ink_bounds(strokes: &[Vec<[f64; 2]>]) -> Option<[f64; 4]> { + let controls = strokes.iter().filter(|s| s.len() > 2).flat_map(|s| { + let pts: Vec<(f64, f64)> = s.iter().map(|p| (p[0], p[1])).collect(); + appearance::smooth_segments(&pts).into_iter().flat_map(|[a, b, _]| [[a.0, a.1], [b.0, b.1]]) + }); + bounds(strokes.iter().flatten().copied().chain(controls)) +} + +pub(crate) fn ending_draws(ending: LineEnding) -> bool { + ending != LineEnding::None +} + +pub(crate) fn ending_pair(start: LineEnding, end: LineEnding) -> Object { + Object::Array(vec![Object::name(start.name()), Object::name(end.name())]) +} + +/// Validate and compute `/Rect` for a new comment. +pub(crate) fn rect_for(shape: &Shape, style: &Style) -> Result<[f64; 4], AnnotError> { + let bad = |what: &str| AnnotError::Invalid(format!("invalid {what}")); + let half = style.width.max(0.0) / 2.0; + let r = match shape { + Shape::Note { at, .. } | Shape::Attachment { at, .. } => { + if !finite(at) { + return Err(bad("position")); + } + [at[0], at[1] - NOTE_SIZE, at[0] + NOTE_SIZE, at[1]] + } + Shape::TextMarkup { quads, .. } | Shape::Redact { quads, .. } => { + if quads.is_empty() || !quads.iter().all(|q| finite(q)) { + return Err(bad("text area (no quadrilaterals)")); + } + bounds(quads.iter().flat_map(|q| q.as_chunks::<2>().0.iter().map(|p| [p[0], p[1]]).collect::>())) + .ok_or_else(|| bad("text area"))? + } + Shape::Rectangle { rect } + | Shape::Oval { rect } + | Shape::TextBox { rect, .. } + | Shape::Typewriter { rect, .. } + | Shape::Stamp { rect, .. } + | Shape::TypedSignature { rect, .. } + | Shape::Mark { rect, .. } => { + let r = normalize(*rect); + if !finite(rect) || r[2] - r[0] < 1.0 || r[3] - r[1] < 1.0 { + return Err(bad("rectangle (too small)")); + } + r + } + Shape::CustomStamp { rect, .. } => { + let r = normalize(*rect); + // Image signatures may be very thin; a positive PDF appearance box still works. + if !finite(rect) || r[2] <= r[0] || r[3] <= r[1] { + return Err(bad("rectangle (empty)")); + } + r + } + Shape::Line { from, to, start, end } => { + if !finite(from) || !finite(to) || (from[0] - to[0]).hypot(from[1] - to[1]) < 1.0 { + return Err(bad("line (too short)")); + } + let pad = half + if ending_draws(*start) || ending_draws(*end) { appearance::arrow_size(style.width) } else { 0.0 }; + grow(bounds([*from, *to].into_iter()).unwrap_or_default(), pad + 1.0) + } + Shape::Ink { strokes } | Shape::FreehandHighlight { strokes } | Shape::Signature { strokes } => { + if strokes.iter().all(Vec::is_empty) || !strokes.iter().flatten().all(|p| finite(p)) { + return Err(bad("drawing (no points)")); + } + grow(ink_bounds(strokes).unwrap_or_default(), half + 1.0) + } + Shape::Polygon { vertices, cloud } => { + let b = bounds(vertices.iter().copied()) + .filter(|b| vertices.len() >= 3 && vertices.iter().all(|p| finite(p)) && b[2] - b[0] >= 1.0 && b[3] - b[1] >= 1.0); + let b = b.ok_or_else(|| bad("polygon (it needs three points)"))?; + grow(b, half + 1.0 + if *cloud { 1.5 * appearance::cloud_radius(style.width) } else { 0.0 }) + } + Shape::PolyLine { vertices, start, end } => { + let b = bounds(vertices.iter().copied()) + .filter(|b| vertices.len() >= 2 && vertices.iter().all(|p| finite(p)) && (b[2] - b[0]).max(b[3] - b[1]) >= 1.0); + let extra = if ending_draws(*start) || ending_draws(*end) { appearance::arrow_size(style.width) } else { 0.0 }; + grow(b.ok_or_else(|| bad("connected lines (they need two points)"))?, half + 1.0 + extra) + } + Shape::Callout { rect, knee, point, .. } => { + let r = normalize(*rect); + if !finite(rect) || !finite(knee) || !finite(point) || r[2] - r[0] < 1.0 || r[3] - r[1] < 1.0 { + return Err(bad("callout")); + } + let pad = half + appearance::arrow_size(style.width) + 1.0; + let lead = grow(bounds([*knee, *point].into_iter()).unwrap_or_default(), pad); + [r[0].min(lead[0]), r[1].min(lead[1]), r[2].max(lead[2]), r[3].max(lead[3])] + } + Shape::Caret { rect } => { + let r = normalize(*rect); + if !finite(rect) || r[2] - r[0] < 1.0 || r[3] - r[1] < 1.0 { + return Err(bad("caret")); + } + r + } + }; + Ok(r) +} + +pub(crate) fn base_dict(subtype: &str, rect: [f64; 4], page: ObjRef, contents: &str, author: &str, meta: &Meta) -> Dict { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("Annot")); + d.set(b"Subtype".to_vec(), Object::name(subtype)); + d.set(b"Rect".to_vec(), num_array(&rect)); + d.set(b"Contents".to_vec(), PdfString::text(contents)); + if !author.is_empty() { + d.set(b"T".to_vec(), PdfString::text(author)); + } + if let Some(date) = &meta.date { + d.set(b"M".to_vec(), PdfString::literal(date.as_bytes().to_vec())); + d.set(b"CreationDate".to_vec(), PdfString::literal(date.as_bytes().to_vec())); + } + if !meta.id.is_empty() { + d.set(b"NM".to_vec(), PdfString::text(&meta.id)); + } + d.set(b"P".to_vec(), Object::Ref(page)); + d.set(b"F".to_vec(), Object::Int(FLAG_PRINT)); + d +} + +/// Acrobat's `/Subj` for each tool (shown as the comment type in other viewers). +fn subject(shape: &Shape) -> &'static str { + match shape { + Shape::Note { .. } => "Sticky Note", + Shape::TextMarkup { kind: Markup::Highlight, .. } => "Highlight", + Shape::TextMarkup { kind: Markup::Underline, .. } => "Underline", + Shape::TextMarkup { kind: Markup::StrikeOut, .. } => "Strikethrough", + Shape::TextMarkup { kind: Markup::Squiggly, .. } => "Squiggly", + Shape::Rectangle { .. } => "Rectangle", + Shape::Oval { .. } => "Oval", + Shape::Line { start: LineEnding::None, end: LineEnding::OpenArrow, .. } => "Arrow", + Shape::Line { .. } => "Line", + Shape::FreehandHighlight { .. } => "Freehand Highlight", + Shape::Ink { .. } => "Pencil", + Shape::TextBox { .. } => "Text Box", + Shape::Typewriter { .. } => "Typewriter", + Shape::Mark { mark, .. } => mark.label(), + Shape::Signature { .. } => "Signature", + Shape::Redact { .. } => "Redact", + Shape::Stamp { stamp, .. } => match stamp.group() { + StampGroup::Dynamic => "Dynamic stamp", + StampGroup::SignHere => "Sign Here", + StampGroup::StandardBusiness => "Stamp", + }, + Shape::CustomStamp { .. } => "Stamp", + Shape::TypedSignature { .. } => "Signature", + Shape::Polygon { cloud: true, .. } => "Cloud", + Shape::Polygon { .. } => "Polygon", + Shape::PolyLine { .. } => "Polygonal Line", + Shape::Callout { .. } => "Callout", + Shape::Caret { .. } => "Inserted Text", + Shape::Attachment { .. } => "File Attachment", + } +} + +/// Where a callout's leader line meets its text box: the middle of the side facing `knee`. +pub fn callout_attach(rect: [f64; 4], knee: [f64; 2]) -> [f64; 2] { + let [x0, y0, x1, y1] = normalize(rect); + let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0); + if knee[0] < x0 { + [x0, cy] + } else if knee[0] > x1 { + [x1, cy] + } else if knee[1] > y1 { + [cx, y1] + } else { + [cx, y0] + } +} + +/// Add a comment; returns its index in the page's `/Annots`. +pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> Result { + let page = page_ref(doc, new.page)?; + let style = &new.style; + validate_style(style)?; + let rect = rect_for(&new.shape, style)?; + let mut d = base_dict(new.shape.subtype(), rect, page, &new.contents, &new.author, meta); + d.set(b"Subj".to_vec(), PdfString::text(subject(&new.shape))); + let opacity = style.opacity.clamp(0.0, 1.0); + set_non_opaque_style(&mut d, opacity); + let border = |d: &mut Dict| { + let mut bs = Dict::new(); + bs.set(b"W".to_vec(), Object::Real(style.width.max(0.0))); + bs.set(b"S".to_vec(), Object::name("S")); + d.set(b"BS".to_vec(), Object::Dict(bs)); + }; + let mut popup = None; + match &new.shape { + Shape::Note { icon, .. } => { + d.set(b"C".to_vec(), rgb(style.color)); + d.set(b"Name".to_vec(), Object::name(icon.name())); + d.set(b"F".to_vec(), Object::Int(FLAG_PRINT | FLAG_NO_ZOOM | FLAG_NO_ROTATE)); + d.set(b"Open".to_vec(), Object::Bool(false)); + popup = Some([rect[2] + 10.0, rect[3] - 120.0, rect[2] + 210.0, rect[3]]); + } + Shape::TextMarkup { quads, .. } => { + d.set(b"C".to_vec(), rgb(style.color)); + d.set(b"QuadPoints".to_vec(), num_array(&quads.concat())); + } + Shape::Redact { quads, overlay, look } => set_redaction_properties(&mut d, style, quads, overlay, look), + Shape::Rectangle { .. } | Shape::Oval { .. } => { + d.set(b"C".to_vec(), rgb(style.color)); + if let Some(f) = style.fill { + d.set(b"IC".to_vec(), rgb(f)); + } + border(&mut d); + } + Shape::Line { from, to, start, end } => { + d.set(b"C".to_vec(), rgb(style.color)); + d.set(b"L".to_vec(), num_array(&[from[0], from[1], to[0], to[1]])); + if ending_draws(*start) || ending_draws(*end) { + d.set(b"LE".to_vec(), ending_pair(*start, *end)); + } + border(&mut d); + } + Shape::Mark { mark, .. } => { + d.set(b"C".to_vec(), rgb(style.color)); + d.set(b"Name".to_vec(), Object::name(mark.name())); + border(&mut d); + // Drawn upright for the page as displayed, as a stamp is (#2392). + let turn = pdfcraft_model::pages(doc).get(new.page).map_or(0, |p| p.rotation(doc)); + if matches!(turn, 90 | 180 | 270) { + d.set(b"PCStampRotate".to_vec(), Object::Int(turn)); + } + } + Shape::Stamp { stamp, by, .. } => { + d.set(b"C".to_vec(), rgb(style.color)); + d.set(b"Name".to_vec(), Object::name(stamp.name())); + // Marks the stamp as drawn by PdfCraft: other stamps with standard names keep + // their own artwork. + d.set(b"PCStamp".to_vec(), Object::Bool(true)); + if let Some(b) = by { + d.set(b"PCByLine".to_vec(), PdfString::text(b)); + } + // The label is drawn for the page as displayed. A later page rotation is a new edit. + let turn = pdfcraft_model::pages(doc).get(new.page).map_or(0, |p| p.rotation(doc)); + if matches!(turn, 90 | 180 | 270) { + d.set(b"PCStampRotate".to_vec(), Object::Int(turn)); + } + } + Shape::TypedSignature { contours, .. } => { + d.set(b"C".to_vec(), rgb(style.color)); + d.set(b"Name".to_vec(), Object::name("PCTypedSignature")); + let list = contours.iter().filter(|c| c.len() > 2).map(|c| num_array(&c.iter().flat_map(|p| [p[0], p[1]]).collect::>())).collect(); + d.set(b"PCOutline".to_vec(), Object::Array(list)); + } + Shape::CustomStamp { name, picture, image, size, .. } => { + let clean: String = name.chars().filter(char::is_ascii_alphanumeric).collect(); + d.set(b"Name".to_vec(), Object::name(&format!("PCCustom{clean}"))); + d.set(b"PCPicture".to_vec(), Object::Ref(*picture)); + d.set(b"PCPictureImage".to_vec(), Object::Bool(*image)); + d.set(b"PCPictureSize".to_vec(), num_array(&[size.0, size.1])); + // An image signature's rectangle is in user space; on a turned page its appearance is + // turned back (see `appearance::build`), so it reads upright as displayed. + if *image && matches!(clean.as_str(), "Signature" | "Initials") { + let turn = pdfcraft_model::pages(doc).get(new.page).map_or(0, |p| p.rotation(doc)); + if turn != 0 { + d.set(b"PCPictureRotate".to_vec(), Object::Int(turn)); + } + } + } + Shape::Ink { strokes } | Shape::FreehandHighlight { strokes } | Shape::Signature { strokes } => { + d.set(b"C".to_vec(), rgb(style.color)); + let list = strokes.iter().filter(|s| !s.is_empty()).map(|s| num_array(&s.concat())).collect(); + d.set(b"InkList".to_vec(), Object::Array(list)); + if matches!(&new.shape, Shape::FreehandHighlight { .. }) { + d.set(b"IT".to_vec(), Object::name("FreehandHighlight")); + } + border(&mut d); + } + Shape::Polygon { vertices, cloud } => { + d.set(b"C".to_vec(), rgb(style.color)); + if let Some(f) = style.fill { + d.set(b"IC".to_vec(), rgb(f)); + } + d.set(b"Vertices".to_vec(), num_array(&vertices.concat())); + if *cloud { + d.set(b"IT".to_vec(), Object::name("PolygonCloud")); + let mut be = Dict::new(); + be.set(b"S".to_vec(), Object::name("C")); + be.set(b"I".to_vec(), Object::Int(1)); + d.set(b"BE".to_vec(), Object::Dict(be)); + } + border(&mut d); + } + Shape::PolyLine { vertices, start, end } => { + d.set(b"C".to_vec(), rgb(style.color)); + d.set(b"Vertices".to_vec(), num_array(&vertices.concat())); + if ending_draws(*start) || ending_draws(*end) { + d.set(b"LE".to_vec(), ending_pair(*start, *end)); + } + border(&mut d); + } + Shape::Caret { .. } => { + d.set(b"C".to_vec(), rgb(style.color)); + d.set(b"Sy".to_vec(), Object::name("None")); + } + Shape::Attachment { icon, file, data, .. } => { + if file.trim().is_empty() { + return Err(AnnotError::Invalid("the attached file needs a name".into())); + } + d.set(b"C".to_vec(), rgb(style.color)); + d.set(b"Name".to_vec(), Object::name(icon.name())); + d.set(b"F".to_vec(), Object::Int(FLAG_PRINT | FLAG_NO_ZOOM | FLAG_NO_ROTATE)); + // The file: an embedded file stream inside a file specification (§7.11.4). + let mut params = Dict::new(); + params.set(b"Size".to_vec(), Object::Int(data.len() as i64)); + if let Some(date) = &meta.date { + params.set(b"ModDate".to_vec(), PdfString::literal(date.as_bytes().to_vec())); + } + let mut ef = Dict::new(); + ef.set(b"Type".to_vec(), Object::name("EmbeddedFile")); + ef.set(b"Params".to_vec(), Object::Dict(params)); + let ef = doc.add(Object::Stream(pdfcraft_cos::Stream::flate(ef, data))); + let mut efd = Dict::new(); + efd.set(b"F".to_vec(), Object::Ref(ef)); + efd.set(b"UF".to_vec(), Object::Ref(ef)); + let mut fs = Dict::new(); + fs.set(b"Type".to_vec(), Object::name("Filespec")); + fs.set(b"F".to_vec(), PdfString::text(file.trim())); + fs.set(b"UF".to_vec(), PdfString::text(file.trim())); + fs.set(b"EF".to_vec(), Object::Dict(efd)); + d.set(b"FS".to_vec(), Object::Ref(doc.add(Object::Dict(fs)))); + if new.contents.is_empty() { + d.set(b"Contents".to_vec(), PdfString::text(file.trim())); + } + } + Shape::Callout { rect: tb, knee, point, ending, .. } => { + let tb = normalize(*tb); + let attach = callout_attach(tb, *knee); + d.set(b"IT".to_vec(), Object::name("FreeTextCallout")); + d.set(b"CL".to_vec(), num_array(&[point[0], point[1], knee[0], knee[1], attach[0], attach[1]])); + d.set(b"LE".to_vec(), Object::name(ending.name())); + // The text box inside /Rect (§12.5.6.6 /RD). + d.set(b"RD".to_vec(), num_array(&[tb[0] - rect[0], tb[1] - rect[1], rect[2] - tb[2], rect[3] - tb[3]])); + } + _ => {} + } + match &new.shape { + Shape::TextBox { font_size, .. } | Shape::Typewriter { font_size, .. } | Shape::Callout { font_size, .. } => { + if matches!(new.shape, Shape::Typewriter { .. }) { + d.set(b"IT".to_vec(), Object::name("FreeTextTypeWriter")); + } + let size = if font_size.is_finite() && *font_size > 0.0 { font_size.clamp(1.0, 400.0) } else { 12.0 }; + let [r, g, b] = style.color.map(|x| x.clamp(0.0, 1.0)); + d.set(b"DA".to_vec(), PdfString::literal(format!("{} {} {} rg /Helv {} Tf", n(r), n(g), n(b), n(size)).into_bytes())); + d.set(b"Q".to_vec(), Object::Int(0)); + if let Some(f) = style.fill { + d.set(b"C".to_vec(), rgb(f)); + } + border(&mut d); + rich_text(&mut d); + // The text is laid out for the page as displayed, like a stamp's label. + if !matches!(new.shape, Shape::Callout { .. }) { + let turn = pdfcraft_model::pages(doc).get(new.page).map_or(0, |p| p.rotation(doc)); + if matches!(turn, 90 | 180 | 270) { + d.set(b"PCTextRotate".to_vec(), Object::Int(turn)); + } + } + } + _ => {} + } + if fill_sign_shape(&new.shape) { + // Survives a later subject edit, so flatten-on-save still finds the mark. + d.set(b"PCFillSign".to_vec(), Object::Bool(true)); + } + // A redaction's overlay text becomes page content when it is applied, where `?` would be there + // for good: refused before anything is added. A comment's own text is stored as typed and + // drawn with `?` for what the standard fonts can't draw (marked, see `draw_appearance`). + if d.name(b"Subtype") == Some(b"Redact") + && let Some((what, c)) = appearance::undrawable_text(&d) + { + return Err(AnnotError::Undrawable(pdfcraft_fonts::undrawable_message(what, c))); + } + let r = doc.add(Object::Dict(d.clone())); + draw_appearance(doc, r)?; + let mut list = annots(doc, page); + let index = list.len(); + list.push(Object::Ref(r)); + add_popup(doc, r, popup, &mut list)?; + set_annots(doc, page, list)?; + Ok(index) +} + +fn validate_style(style: &Style) -> Result<(), AnnotError> { + if !finite(&style.color) || !style.opacity.is_finite() || !style.width.is_finite() { + return Err(AnnotError::Invalid("invalid style".into())); + } + Ok(()) +} + +fn set_redaction_properties(dict: &mut Dict, style: &Style, quads: &[[f64; 8]], overlay: &str, look: &OverlayLook) { + dict.set(b"C".to_vec(), rgb(style.color)); + dict.set(b"IC".to_vec(), rgb(style.fill.unwrap_or([0.0, 0.0, 0.0]))); + dict.set(b"QuadPoints".to_vec(), num_array(&quads.concat())); + if !overlay.is_empty() { + dict.set(b"OverlayText".to_vec(), PdfString::text(overlay)); + let [r, g, b] = look.color.map(|x| x.clamp(0.0, 1.0)); + let size = if look.size > 0.0 { look.size.min(400.0) } else { 0.0 }; + dict.set(b"DA".to_vec(), PdfString::literal(format!("{} {} {} rg /{} {} Tf", n(r), n(g), n(b), look.font.resource(), n(size)).into_bytes())); + dict.set(b"Q".to_vec(), Object::Int(look.align.min(2) as i64)); + if look.repeat { + dict.set(b"Repeat".to_vec(), Object::Bool(true)); + } + } +} + +fn set_non_opaque_style(dict: &mut Dict, opacity: f64) { + if opacity < 1.0 { + dict.set(b"CA".to_vec(), Object::Real(opacity)); + } +} + +fn add_popup(doc: &mut Document, parent: ObjRef, rect: Option<[f64; 4]>, annots: &mut Vec) -> Result<(), AnnotError> { + let Some(rect) = rect else { return Ok(()) }; + let mut popup = Dict::new(); + popup.set(b"Type".to_vec(), Object::name("Annot")); + popup.set(b"Subtype".to_vec(), Object::name("Popup")); + popup.set(b"Rect".to_vec(), num_array(&rect)); + popup.set(b"Parent".to_vec(), Object::Ref(parent)); + popup.set(b"Open".to_vec(), Object::Bool(false)); + popup.set(b"F".to_vec(), Object::Int(FLAG_PRINT | FLAG_NO_ZOOM | FLAG_NO_ROTATE)); + let popup_ref = doc.add(Object::Dict(popup)); + doc.update_dict(parent, |dict| dict.set(b"Popup".to_vec(), Object::Ref(popup_ref)))?; + annots.push(Object::Ref(popup_ref)); + Ok(()) +} + +/// Counterrotate a newly placed picture stamp's appearance into displayed-page axes. +/// The picture may be an image or a PDF page. The normal appearance's transformed bounding +/// box is fitted to `/Rect` by PDF viewers. +pub fn orient_image_stamp(doc: &mut Document, page: usize, index: usize, rotation: i64) -> Result<(), AnnotError> { + let matrix = match rotation { + 90 => [0.0, 1.0, -1.0, 0.0, 0.0, 0.0], + 180 => [-1.0, 0.0, 0.0, -1.0, 0.0, 0.0], + 270 => [0.0, -1.0, 1.0, 0.0, 0.0, 0.0], + _ => return Ok(()), + }; + let (_, r) = annot_ref(doc, page, index)?; + let d = annot_dict(doc, r); + if d.name(b"Subtype") != Some(b"Stamp") || d.get(b"PCPicture").is_none() { + return Err(AnnotError::Invalid("only a picture stamp can be oriented".into())); + } + let normal = d + .get(b"AP") + .map(|ap| doc.resolve(ap)) + .and_then(|ap| ap.as_dict().and_then(|ap| ap.reference(b"N"))) + .ok_or_else(|| AnnotError::Invalid("the stamp has no normal appearance".into()))?; + let Object::Stream(mut stream) = doc.get(normal).as_ref().clone() else { + return Err(AnnotError::Invalid("the stamp's normal appearance is not a stream".into())); + }; + stream.dict.set(b"Matrix".to_vec(), num_array(&matrix)); + doc.set(normal, Object::Stream(stream)); + Ok(()) +} diff --git a/crates/annot/src/draw.rs b/crates/annot/src/draw.rs new file mode 100644 index 0000000000..5e698332da --- /dev/null +++ b/crates/annot/src/draw.rs @@ -0,0 +1,198 @@ +use super::create::{finite, num_array}; +use super::lookup::{annot_dict, annots, page_refs}; +use super::modify::refuse_foreign_text; +use super::types::NOTE_SIZE; +use super::{AnnotError, Dict, Document, ObjRef, Object, Stream, appearance}; + +/// (Re)generate `/AP /N` for the annotation `r` from its dictionary, for an edit that keeps its +/// text (a restyle, a new size or colour). +/// +/// A comment whose text the standard fonts can't draw, with an appearance another program drew +/// (with a font that has those characters), keeps that appearance: redrawing it would put `?` in +/// their place, so the edit is refused (`AnnotError::Undrawable`). One PdfCraft drew with `?` +/// (marked `pdfcraft_fonts::UNDRAWN_MARK`) is redrawn. +pub fn set_appearance(doc: &mut Document, r: ObjRef) -> Result<(), AnnotError> { + refuse_foreign_text(doc, &annot_dict(doc, r))?; + draw_appearance(doc, r) +} + +/// Put `before` back when redrawing fails, so a refused edit does not keep the new rectangle or style. +pub(crate) fn redraw_or_restore(doc: &mut Document, r: ObjRef, before: Dict) -> Result<(), AnnotError> { + if let Err(e) = set_appearance(doc, r) { + doc.set(r, Object::Dict(before)); + return Err(e); + } + Ok(()) +} + +/// Draw `r`'s normal appearance from its dictionary, whatever its text: characters the standard +/// fonts can't draw come out as `?`, and the annotation is marked (`pdfcraft_fonts::UNDRAWN_MARK`, +/// cleared when all of it can be drawn) so the edit is reported and flattening won't make that +/// `?` permanent. For new text (a new comment, changed contents), as Acrobat keeps text its font +/// can't show, and for display-only copies. +pub(crate) fn draw_appearance(doc: &mut Document, r: ObjRef) -> Result<(), AnnotError> { + let d = annot_dict(doc, r); + let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned(); + let Some(stream) = build_appearance(doc, &d) else { return Err(AnnotError::Unsupported(subtype)) }; + apply_appearance(doc, r, &d, stream) +} + +/// Draw the appearance for an annotation dictionary: the expensive half of [`set_appearance`]. +/// Pure from the dictionary and the objects it points at, so [`AppearanceCache`] can reuse it. +fn build_appearance(doc: &Document, d: &Dict) -> Option { + let mut stream = appearance::build(d)?; + // Picture-stamp restyling must retain the placement's page-axis correction. + if d.get(b"PCPicture").is_some() { + let matrix = d.get(b"AP").and_then(|ap| { + let ap = doc.resolve(ap); + let normal = doc.resolve(ap.as_dict()?.get(b"N")?); + let Object::Stream(normal) = normal.as_ref() else { return None }; + normal.dict.get(b"Matrix").cloned() + }); + if let Some(matrix) = matrix { + stream.dict.set(b"Matrix".to_vec(), matrix); + } + } + Some(stream) +} + +/// Store a built appearance on the annotation: the cheap half of [`set_appearance`]. +fn apply_appearance(doc: &mut Document, r: ObjRef, d: &Dict, stream: Stream) -> Result<(), AnnotError> { + // Characters the standard fonts can't draw came out as `?`: mark the annotation with the first. + let missing = appearance::undrawable_text(d).map(|(_, c)| c); + let ap = doc.add(Object::Stream(stream)); + // A copy (a shared /AP is left alone) that keeps unknown entries; the old down and rollover + // appearances would show the previous look on press or hover, so they go with the old /N. + let mut apd = d.get(b"AP").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()).unwrap_or_default(); + apd.remove(b"D"); + apd.remove(b"R"); + apd.set(b"N".to_vec(), Object::Ref(ap)); + // A note icon has a fixed size (`NOTE_SIZE`); viewers draw it at the upper-left corner of `/Rect`. + // The appearance's box is mapped onto `/Rect`, so any other `/Rect` (a large one, or one with + // no width, as LaTeX `pdfcomment` writes) would stretch the icon or shrink it to nothing. + let icon_rect = matches!(d.name(b"Subtype"), Some(b"Text" | b"FileAttachment")) + .then(|| d.get(b"Rect")?.as_array()?.iter().map(|o| o.as_f64()).collect::>>()) + .flatten() + .filter(|q| q.len() == 4 && finite(q)) + .map(|q| { + let (x, top) = (q[0].min(q[2]), q[1].max(q[3])); + [x, top - NOTE_SIZE, x + NOTE_SIZE, top] + }); + doc.update_dict(r, |d| { + d.set(b"AP".to_vec(), Object::Dict(apd)); + if let Some(rect) = icon_rect { + d.set(b"Rect".to_vec(), num_array(&rect)); + } + d.remove(b"AS"); + pdfcraft_fonts::set_undrawn_mark(d, missing); + })?; + Ok(()) +} + +/// Display appearances built for annotations without any, reused while the annotation +/// dictionaries they were drawn from are unchanged. +/// +/// A content edit (moving a paragraph, retyping text, …) leaves every annotation dictionary +/// alone, but the display copy is rebuilt from the edited document on every refresh: without +/// this cache each refresh redrew every appearance (27k on one CAD file, ~0.7 s per edit +/// commit). Entries are keyed by annotation reference; object numbers are never reused within +/// a document lineage, so an entry stays valid until an annotation edit runs. Callers clear +/// the cache on any refresh that may change annotations (comments, structural and full +/// refreshes) and keep it for content-only ones. +#[derive(Clone, Debug, Default)] +pub struct AppearanceCache { + entries: std::collections::HashMap, + bytes: usize, + hits: u64, + builds: u64, +} + +impl AppearanceCache { + /// Cached appearance data restarts past this instead of growing without bound. + const MAX_BYTES: usize = 64 << 20; + + /// Forget every entry (an annotation edit may have changed the dictionaries). + pub fn clear(&mut self) { + *self = Self::default(); + } + + fn get(&mut self, r: ObjRef) -> Option { + let stream = self.entries.get(&r)?.clone(); + self.hits += 1; + Some(stream) + } + + fn insert(&mut self, r: ObjRef, stream: &Stream) { + self.builds += 1; + if self.entries.len() >= 1_000_000 { + self.clear(); + } + if self.bytes.saturating_add(stream.raw.len()) <= Self::MAX_BYTES { + self.bytes += stream.raw.len(); + self.entries.insert(r, stream.clone()); + } + // Else over budget: keep serving the entries already cached instead of growing. + } + + /// Cache hits so far (reused appearances); for tests. + pub fn hits(&self) -> u64 { + self.hits + } + + /// Appearances built so far; for tests. + pub fn builds(&self) -> u64 { + self.builds + } +} + +/// A copy of `doc` in which every annotation without a normal appearance (`/AP /N`) has the one +/// [`appearance::build`] draws from its dictionary, for displaying the document (`None` when no +/// annotation needs one). Many files carry comments without appearances (FreeText, Ink, notes, +/// stamps, …) that viewers draw from the dictionary. `doc` is not changed: saving writes the file +/// as it was. Links, form fields and pop-ups keep their own handling. +pub fn with_missing_appearances(doc: &Document) -> Option { + with_missing_appearances_cached(doc, &mut AppearanceCache::default()) +} + +/// A copy of `doc` with [`AppearanceCache`] reuse across refreshes: annotations whose +/// dictionary is unchanged since the cached build keep their stored appearance instead of +/// redrawing it. See [`AppearanceCache`] for when entries stay valid. +pub fn with_missing_appearances_cached(doc: &Document, cache: &mut AppearanceCache) -> Option { + let mut copy: Option = None; + // An annotation listed more than once, on one page or several, is drawn once. `has_normal` + // reads `doc`, which never gets the appearance, so every repeat used to add another stream to + // the copy, and the display bytes grew with the number of listings. + let mut seen = std::collections::HashSet::new(); + for page in page_refs(doc).ok()? { + for r in annots(doc, page).iter().filter_map(Object::as_ref) { + if !seen.insert(r) { + continue; + } + let d = annot_dict(doc, r); + if matches!(d.name(b"Subtype"), None | Some(b"Link" | b"Widget" | b"Popup")) { + continue; + } + let has_normal = d.get(b"AP").is_some_and(|ap| doc.resolve(ap).as_dict().is_some_and(|ap| ap.contains(b"N"))); + if has_normal { + continue; + } + if let Some(stream) = cache.get(r) { + // Fails only when `r` isn't a dictionary, which the cached build ruled out. + apply_appearance(copy.get_or_insert_with(|| doc.clone()), r, &d, stream).ok(); + continue; + } + // Another program's stamp gets a drawing for display only (ISO 32000-2 Table 184). Its + // `/AP`, if any, has no `/N` to keep here. + let other_stamp = || { + let mut bare = d.clone(); + bare.remove(b"AP"); + appearance::display_stamp(&bare) + }; + let Some(stream) = build_appearance(doc, &d).or_else(other_stamp) else { continue }; + cache.insert(r, &stream); + // Fails only when `r` isn't a dictionary, which the build just ruled out. + apply_appearance(copy.get_or_insert_with(|| doc.clone()), r, &d, stream).ok(); + } + } + copy +} diff --git a/crates/annot/src/info.rs b/crates/annot/src/info.rs new file mode 100644 index 0000000000..1a54dbc077 --- /dev/null +++ b/crates/annot/src/info.rs @@ -0,0 +1,280 @@ +use super::create::finite; +use super::draw::set_appearance; +use super::lookup::{annot_dict, annot_ref, annotation_flags, annots, drawn_upright, is_fill_sign, page_ref, page_refs}; +use super::modify::{fillable, touch, unlocked}; +use super::types::{FLAG_LOCKED, LineEnding, Meta, NoteIcon, Rgb}; +use super::{AnnotError, Dict, Document, ObjRef, Object, PdfString, appearance}; + +// ── reading ───────────────────────────────────────────────────────────────────────────────── + +/// One comment as the viewer lists it (the same fields and rules as +/// `pdfcraft_render::Annotation`), read straight from the object graph. The engine uses it to +/// refresh the comment list after a comment edit without re-inspecting the whole document. +#[derive(Clone, Debug, PartialEq)] +pub struct Summary { + pub page: usize, + pub index: usize, + pub subtype: String, + pub author: Option, + pub contents: Option, + /// `/M` as written (a PDF date string). + pub modified: Option, + pub name: Option, + pub in_reply_to: Option, + /// The page and `/Annots` position `/IRT` points at, by reference (`/NM` need not be unique). + pub parent: Option<(usize, usize)>, + /// `/RT /Group`: grouped with the `/IRT` annotation (Replace Text's caret), not a reply to it. + pub grouped: bool, + pub rect: [f32; 4], + pub color: Option<[f32; 3]>, + pub state: Option, + pub quads: Vec<[f32; 8]>, + pub locked: bool, + pub intent: Option, + /// A Fill & Sign mark ([`is_fill_sign`]). + pub fill_sign: bool, + /// The NoRotate flag (`/F` bit 5), as `pdfcraft_render::Annotation::no_rotate`. + pub no_rotate: bool, + /// A line's two ends (`/L`), or the corners of connected lines, a polygon or a cloud + /// (`/Vertices`), in user space; empty for other comments. + pub points: Vec<[f32; 2]>, +} + +pub(crate) fn text_value(doc: &Document, d: &Dict, key: &[u8]) -> Option { + let o = doc.resolve(d.get(key)?); + let s = match &*o { + Object::String(s) => s.to_text(), + Object::Name(n) => String::from_utf8_lossy(n).into_owned(), + _ => return None, + }; + let s = s.trim_matches('\0'); + // Typewriter outer whitespace belongs to the text (keep in step with render's inspector). + let name_is = |key: &[u8], expected: &[u8]| d.get(key).is_some_and(|o| doc.resolve(o).as_name() == Some(expected)); + let keep_breaks = key == b"Contents" && name_is(b"Subtype", b"FreeText") && name_is(b"IT", b"FreeTextTypeWriter"); + let s = if keep_breaks { s } else { s.trim() }; + (!s.trim().is_empty()).then(|| s.to_owned()) +} + +/// Whether an annotation subtype is a comment. Links, form widgets, pop-ups and the non-markup +/// multimedia and print-production annotations (ISO 32000-2 §12.5.6: Screen, Movie, RichMedia, +/// 3D, PrinterMark, TrapNet, Watermark) are not. +pub fn is_comment_subtype(subtype: &str) -> bool { + !matches!(subtype, "Link" | "Widget" | "Popup" | "Screen" | "Movie" | "RichMedia" | "3D" | "PrinterMark" | "TrapNet" | "Watermark") +} + +/// Where a reference to a reference (`5 0 obj 6 0 R endobj`) ends, as `pdfcraft_render::inspect` +/// follows it, within a few steps (a cycle stops there). +fn final_ref(doc: &Document, mut r: ObjRef) -> ObjRef { + for _ in 0..8 { + match &*doc.get(r) { + Object::Ref(next) => r = *next, + _ => break, + } + } + r +} + +/// Every comment (see [`is_comment_subtype`]), ordered by page and then top edge. +pub fn summaries(doc: &Document) -> Vec { + let mut out = Vec::new(); + let Ok(pages) = page_refs(doc) else { return out }; + let lists: Vec> = pages.iter().map(|p| annots(doc, *p)).collect(); + // Where each annotation is first listed, so a reply's `/IRT` finds its parent by reference. + let mut slots: std::collections::HashMap = std::collections::HashMap::new(); + for (page, list) in lists.iter().enumerate() { + for (index, entry) in list.iter().enumerate() { + if let Some(r) = entry.as_ref() { + slots.entry(final_ref(doc, r)).or_insert((page, index)); + } + } + } + for (page, list) in lists.iter().enumerate() { + for (index, entry) in list.iter().enumerate() { + let obj = entry.as_ref().map_or_else(|| doc.resolve(entry), |r| doc.get(final_ref(doc, r))); + let Some(d) = obj.as_dict() else { continue }; + let Some(subtype) = d.name(b"Subtype").map(|s| String::from_utf8_lossy(s).into_owned()) else { continue }; + if !is_comment_subtype(&subtype) { + continue; + } + let nums = |k: &[u8]| -> Vec { + d.get(k) + .map(|o| doc.resolve(o)) + .and_then(|o| o.as_array().map(|a| a.iter().map(|x| doc.resolve(x).as_f64().unwrap_or(0.0) as f32).collect())) + .unwrap_or_default() + }; + let r = nums(b"Rect"); + let rect = if r.len() == 4 { [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])] } else { [0.0; 4] }; + let c = nums(b"C"); + let mut color = (c.len() == 3).then(|| [c[0], c[1], c[2]]); + if subtype == "FreeText" + && let Some(da) = text_value(doc, d, b"DA") + { + let t: Vec<&str> = da.split_whitespace().collect(); + if let Some(i) = t.iter().position(|x| *x == "rg") + && i >= 3 + { + let f = |k: usize| t[k].parse::().unwrap_or(0.0); + color = Some([f(i - 3), f(i - 2), f(i - 1)]); + } + } + let quads = nums(b"QuadPoints").as_chunks::<8>().0.to_vec(); + // Keep in step with `pdfcraft_render::inspect`. + let points = match subtype.as_str() { + "Line" => Some(nums(b"L")).filter(|l| l.len() == 4).map(|l| l.as_chunks::<2>().0.to_vec()).unwrap_or_default(), + "PolyLine" | "Polygon" => nums(b"Vertices").as_chunks::<2>().0.to_vec(), + _ => Vec::new(), + }; + let irt = d.get(b"IRT").map(|o| o.as_ref().map_or_else(|| doc.resolve(o), |r| doc.get(final_ref(doc, r)))); + let in_reply_to = irt.and_then(|o| o.as_dict().cloned()).and_then(|p| text_value(doc, &p, b"NM")); + let parent = d.get(b"IRT").and_then(Object::as_ref).and_then(|r| slots.get(&final_ref(doc, r)).copied()); + out.push(Summary { + page, + index, + subtype, + author: text_value(doc, d, b"T"), + // Acrobat ends lines with CR, which the interface would draw as nothing (keep in + // step with `pdfcraft_render::inspect`). + contents: text_value(doc, d, b"Contents").map(|s| s.replace("\r\n", "\n").replace('\r', "\n")), + modified: text_value(doc, d, b"M"), + name: text_value(doc, d, b"NM"), + in_reply_to, + parent, + grouped: d.name(b"RT") == Some(b"Group"), + rect, + color, + state: text_value(doc, d, b"State"), + quads, + locked: annotation_flags(doc, d) & FLAG_LOCKED != 0, + intent: d.get(b"IT").and_then(|o| doc.resolve(o).as_name().map(|n| String::from_utf8_lossy(n).into_owned())), + fill_sign: is_fill_sign(doc, d), + no_rotate: drawn_upright(doc, d), + points, + }); + } + } + out.sort_by(|a, b| a.page.cmp(&b.page).then(b.rect[3].total_cmp(&a.rect[3]))); + out +} + +/// Change a comment's author (`/T`), subject (`/Subj`) and, for notes, icon (`/Name`, redrawn). +/// `None` leaves a value as it is. +pub fn set_info( + doc: &mut Document, + page: usize, + index: usize, + author: Option<&str>, + subject: Option<&str>, + icon: Option, + meta: &Meta, +) -> Result<(), AnnotError> { + let (_, r) = annot_ref(doc, page, index)?; + unlocked(doc, r)?; + let is_note = annot_dict(doc, r).name(b"Subtype") == Some(b"Text"); + if icon.is_some() && !is_note { + return Err(AnnotError::Invalid("only sticky notes have an icon".into())); + } + doc.update_dict(r, |d| { + if let Some(a) = author { + d.set(b"T".to_vec(), PdfString::text(a)); + } + if let Some(s) = subject { + d.set(b"Subj".to_vec(), PdfString::text(s)); + } + if let Some(i) = icon { + d.set(b"Name".to_vec(), Object::name(i.name())); + } + touch(d, meta); + })?; + if icon.is_some() { + set_appearance(doc, r)?; + } + Ok(()) +} + +/// What Comment properties shows for one comment. +#[derive(Clone, Debug, PartialEq)] +pub struct Props { + pub subtype: String, + pub author: String, + pub subject: String, + pub color: Option, + pub opacity: f64, + /// Border / line width, for shapes, lines, drawings and text boxes. + pub width: Option, + /// Notes only. + pub icon: Option, + /// `/M`, as written. + pub modified: Option, + /// The appearance can be redrawn (so colour, opacity and width can change). + pub restylable: bool, + /// The Locked flag. + pub locked: bool, + /// `/LE`: two names for a line or polyline (`None` when unset), one for a callout. + pub endings: Option>, + /// Whether the comment has a fill control (see [`fillable`]). + pub fillable: bool, + /// 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)`. +pub fn props(doc: &Document, page: usize, index: usize) -> Option { + let p = page_ref(doc, page).ok()?; + let entry = annots(doc, p).get(index).cloned()?; + let obj = doc.resolve(&entry); + let d = obj.as_dict()?; + let subtype = String::from_utf8_lossy(d.name(b"Subtype")?).into_owned(); + let c: Vec = d.get(b"C").and_then(|o| o.as_array().map(|a| a.iter().filter_map(|x| x.as_f64()).collect())).unwrap_or_default(); + let color = if subtype == "FreeText" { Some(appearance::parse_da(d).0) } else { (c.len() == 3).then(|| [c[0], c[1], c[2]]) }; + let width = matches!(subtype.as_str(), "Square" | "Circle" | "Line" | "Ink" | "Polygon" | "PolyLine" | "FreeText").then(|| { + if subtype == "FreeText" { + if !d.contains(b"BS") && !d.contains(b"Border") { 0.0 } else { appearance::border_width(d) } + } else { + d.get(b"BS").and_then(|b| b.as_dict()).and_then(|b| b.get(b"W")).and_then(|w| w.as_f64()).unwrap_or(1.0) + } + }); + Some(Props { + author: text_value(doc, d, b"T").unwrap_or_default(), + subject: text_value(doc, d, b"Subj").unwrap_or_default(), + color, + opacity: d.get(b"CA").and_then(|o| o.as_f64()).unwrap_or(1.0), + width, + icon: (subtype == "Text").then(|| d.name(b"Name").and_then(|n| NoteIcon::from_name(&String::from_utf8_lossy(n))).unwrap_or(NoteIcon::Note)), + modified: text_value(doc, d, b"M"), + restylable: appearance::build(d).is_some(), + 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), + }) +} + +/// A shape’s `/IC` or FreeText `/C` as RGB. Grey and CMYK fills are converted. +fn fill_of(d: &Dict) -> Option { + let key: &[u8] = if d.name(b"Subtype") == Some(b"FreeText") { b"C" } else { b"IC" }; + let c: Vec = d.get(key)?.as_array()?.iter().filter_map(|x| x.as_f64()).collect(); + let rgb = match c.as_slice() { + [g] => [*g; 3], + [r, g, b] => [*r, *g, *b], + [c, m, y, k] => [(1.0 - c) * (1.0 - k), (1.0 - m) * (1.0 - k), (1.0 - y) * (1.0 - k)], + _ => return None, + }; + finite(&rgb).then(|| rgb.map(|x| x.clamp(0.0, 1.0))) +} + +/// `/LE` for a line, polyline or callout. Unknown names yield `None` so the control stays hidden. +pub(crate) fn line_endings_of(d: &Dict) -> Option> { + let parse = |name: &[u8]| std::str::from_utf8(name).ok().and_then(LineEnding::parse); + match d.name(b"Subtype")? { + b"Line" | b"PolyLine" => match d.get(b"LE") { + None => Some(vec![LineEnding::None, LineEnding::None]), + Some(o) => o.as_array().filter(|a| a.len() == 2)?.iter().map(|e| parse(e.as_name()?)).collect(), + }, + b"FreeText" if d.contains(b"CL") => Some(vec![parse(d.name(b"LE").unwrap_or(b"None"))?]), + _ => None, + } +} diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index 6c9fc5dce8..7bd0894c66 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -18,6 +18,34 @@ use std::collections::{HashMap, HashSet}; use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream}; +mod create; +mod draw; +mod info; +mod lookup; +mod modify; +mod types; + +#[cfg(test)] +use create::num_array; +pub use create::{add_annotation, callout_attach, orient_image_stamp}; +pub use draw::{AppearanceCache, set_appearance, with_missing_appearances, with_missing_appearances_cached}; +pub use info::{Props, Summary, is_comment_subtype, props, set_info, summaries}; +#[cfg(test)] +use lookup::{ANNOTS_ENTRIES_COPIED, PageWalk, annot_dict, annot_ref}; +use lookup::{annots, page_ref}; +pub use lookup::{ + has_visible_fill_sign, is_fill_sign, isolate_annotation, isolate_annotations, page_refs, picture_rotation, set_annots, signature_image, +}; +pub(crate) use modify::n; +pub use modify::{ + add_reply, add_text_replacement, deletable, delete_annotation, erase_ink, fillable, goes_with, lift_annotation, move_annotation, set_contents, + set_fill, set_font_size, set_locked, set_marked, set_points, set_rect, set_review_state, set_style, with_replies_and_popups, +}; +pub use types::{ + AttachIcon, FillMark, LineEnding, Markup, Meta, NOTE_SIZE, NewAnnotation, NoteIcon, OverlayFont, OverlayLook, ReviewState, Rgb, Shape, + StampGroup, StampKind, Style, StyleChange, TextReplacement, rect_quad, +}; + pub mod appearance; pub mod links; @@ -45,2643 +73,3 @@ pub enum AnnotError { #[error("{0}")] Cos(#[from] pdfcraft_cos::CosError), } - -pub type Rgb = [f64; 3]; - -/// Text markup kinds (§12.5.6.10). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Markup { - Highlight, - Underline, - StrikeOut, - Squiggly, -} - -impl Markup { - fn subtype(self) -> &'static str { - match self { - Markup::Highlight => "Highlight", - Markup::Underline => "Underline", - Markup::StrikeOut => "StrikeOut", - Markup::Squiggly => "Squiggly", - } - } -} - -/// Text-annotation icon names (`/Name`). The first seven are the standard names ISO 32000-2 -/// §12.5.6.4, Table 175 asks every viewer to draw; the rest are further names in common use, which -/// the same table allows ("Additional names may be supported as well"). -#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] -pub enum NoteIcon { - #[default] - Comment, - Note, - Help, - Insert, - Key, - NewParagraph, - Paragraph, - Check, - Circle, - Cross, - CrossHairs, - Star, - RightArrow, - UpArrow, -} - -impl NoteIcon { - pub fn name(self) -> &'static str { - match self { - NoteIcon::Comment => "Comment", - NoteIcon::Note => "Note", - NoteIcon::Help => "Help", - NoteIcon::Insert => "Insert", - NoteIcon::Key => "Key", - NoteIcon::NewParagraph => "NewParagraph", - NoteIcon::Paragraph => "Paragraph", - NoteIcon::Check => "Check", - NoteIcon::Circle => "Circle", - NoteIcon::Cross => "Cross", - NoteIcon::CrossHairs => "CrossHairs", - NoteIcon::Star => "Star", - NoteIcon::RightArrow => "RightArrow", - NoteIcon::UpArrow => "UpArrow", - } - } - - /// Every icon, the standard names first. - pub const ALL: [NoteIcon; 14] = [ - Self::Comment, - Self::Note, - Self::Help, - Self::Insert, - Self::Key, - Self::NewParagraph, - Self::Paragraph, - Self::Check, - Self::Circle, - Self::Cross, - Self::CrossHairs, - Self::Star, - Self::RightArrow, - Self::UpArrow, - ]; - - pub fn from_name(n: &str) -> Option { - Self::ALL.into_iter().find(|i| i.name() == n) - } -} - -/// Fill & Sign marks (Acrobat's ✓, ✕, ●, ─). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum FillMark { - Check, - Cross, - Dot, - Line, -} - -impl FillMark { - /// The `/Name` of the stamp PdfCraft draws for it. - pub fn name(self) -> &'static str { - match self { - FillMark::Check => "PCCheck", - FillMark::Cross => "PCCross", - FillMark::Dot => "PCDot", - FillMark::Line => "PCLine", - } - } - - pub fn label(self) -> &'static str { - match self { - FillMark::Check => "Checkmark", - FillMark::Cross => "Cross", - FillMark::Dot => "Dot", - FillMark::Line => "Line", - } - } - - /// The user-space box of the mark placed at `at` on a page turned by `rotation`: centred - /// there and sized in displayed axes, so a line runs across the page as displayed (#2392). - pub fn rect_at(self, at: [f64; 2], rotation: i64) -> [f64; 4] { - let (w, h) = if self == FillMark::Line { (36.0, 4.0) } else { (12.0, 12.0) }; - let (w, h) = if matches!(rotation.rem_euclid(360), 90 | 270) { (h, w) } else { (w, h) }; - [at[0] - w / 2.0, at[1] - h / 2.0, at[0] + w / 2.0, at[1] + h / 2.0] - } -} - -/// The stamps of Acrobat's stamp palette (drawn in PdfCraft's own style). -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub enum StampKind { - // Standard business. - Approved, - Completed, - Confidential, - Draft, - Final, - ForComment, - ForPublicRelease, - InformationOnly, - NotApproved, - NotForPublicRelease, - PreliminaryResults, - Void, - // Sign here. - Accepted, - InitialHere, - Rejected, - SignHere, - Witness, - // Dynamic (with a "By … at …" line). - DynApproved, - DynConfidential, - DynReceived, - DynReviewed, - DynRevised, -} - -/// The palette's sections. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum StampGroup { - Dynamic, - SignHere, - StandardBusiness, -} - -impl StampKind { - pub const ALL: [StampKind; 22] = [ - StampKind::DynApproved, - StampKind::DynConfidential, - StampKind::DynReceived, - StampKind::DynReviewed, - StampKind::DynRevised, - StampKind::Accepted, - StampKind::InitialHere, - StampKind::Rejected, - StampKind::SignHere, - StampKind::Witness, - StampKind::Approved, - StampKind::Completed, - StampKind::Confidential, - StampKind::Draft, - StampKind::Final, - StampKind::ForComment, - StampKind::ForPublicRelease, - StampKind::InformationOnly, - StampKind::NotApproved, - StampKind::NotForPublicRelease, - StampKind::PreliminaryResults, - StampKind::Void, - ]; - - pub fn group(self) -> StampGroup { - use StampKind::*; - match self { - DynApproved | DynConfidential | DynReceived | DynReviewed | DynRevised => StampGroup::Dynamic, - Accepted | InitialHere | Rejected | SignHere | Witness => StampGroup::SignHere, - _ => StampGroup::StandardBusiness, - } - } - - /// The text on the stamp. - pub fn label(self) -> &'static str { - use StampKind::*; - match self { - Approved | DynApproved => "APPROVED", - Completed => "COMPLETED", - Confidential | DynConfidential => "CONFIDENTIAL", - Draft => "DRAFT", - Final => "FINAL", - ForComment => "FOR COMMENT", - ForPublicRelease => "FOR PUBLIC RELEASE", - InformationOnly => "INFORMATION ONLY", - NotApproved => "NOT APPROVED", - NotForPublicRelease => "NOT FOR PUBLIC RELEASE", - PreliminaryResults => "PRELIMINARY RESULTS", - Void => "VOID", - Accepted => "ACCEPTED", - InitialHere => "INITIAL HERE", - Rejected => "REJECTED", - SignHere => "SIGN HERE", - Witness => "WITNESS", - DynReceived => "RECEIVED", - DynReviewed => "REVIEWED", - DynRevised => "REVISED", - } - } - - /// `/Name`: the standard stamp names of ISO 32000-2 Table 184 where one exists. - pub fn name(self) -> &'static str { - use StampKind::*; - match self { - Approved => "Approved", - Confidential => "Confidential", - Draft => "Draft", - Final => "Final", - ForComment => "ForComment", - ForPublicRelease => "ForPublicRelease", - NotApproved => "NotApproved", - NotForPublicRelease => "NotForPublicRelease", - Completed => "PCCompleted", - InformationOnly => "PCInformationOnly", - PreliminaryResults => "PCPreliminaryResults", - Void => "PCVoid", - Accepted => "PCAccepted", - InitialHere => "PCInitialHere", - Rejected => "PCRejected", - SignHere => "PCSignHere", - Witness => "PCWitness", - DynApproved => "PCDynApproved", - DynConfidential => "PCDynConfidential", - DynReceived => "PCDynReceived", - DynReviewed => "PCDynReviewed", - DynRevised => "PCDynRevised", - } - } - - pub fn from_name(n: &[u8]) -> Option { - Self::ALL.into_iter().find(|k| k.name().as_bytes() == n) - } - - /// The stamp's colour: green for approval, red for refusal and restriction, blue otherwise. - pub fn color(self) -> Rgb { - use StampKind::*; - match self { - Approved | Completed | Final | Accepted | DynApproved | DynReceived | DynReviewed => [0.13, 0.55, 0.13], - NotApproved | Rejected | Void | Confidential | NotForPublicRelease | DynConfidential => [0.80, 0.10, 0.10], - SignHere | InitialHere | Witness => [0.85, 0.35, 0.05], - _ => [0.10, 0.30, 0.70], - } - } - - /// The stamp's size (points) for its label, as placed with a click. - pub fn size(self) -> (f64, f64) { - let w = appearance::text_width(self.label(), 16.0) * 1.12 + 24.0; - let h = if self.group() == StampGroup::Dynamic { 42.0 } else { 30.0 }; - let w = if self.group() == StampGroup::SignHere { w + 14.0 } else { w }; - (w.max(80.0), h) - } -} - -/// A line ending (`/LE`): the ten styles in ISO 32000-2 Table 217. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum LineEnding { - None, - Square, - Circle, - Diamond, - OpenArrow, - ClosedArrow, - Butt, - ROpenArrow, - RClosedArrow, - Slash, -} - -impl LineEnding { - pub const ALL: [Self; 10] = [ - Self::None, - Self::Square, - Self::Circle, - Self::Diamond, - Self::OpenArrow, - Self::ClosedArrow, - Self::Butt, - Self::ROpenArrow, - Self::RClosedArrow, - Self::Slash, - ]; - - pub fn name(self) -> &'static str { - match self { - Self::None => "None", - Self::Square => "Square", - Self::Circle => "Circle", - Self::Diamond => "Diamond", - Self::OpenArrow => "OpenArrow", - Self::ClosedArrow => "ClosedArrow", - Self::Butt => "Butt", - Self::ROpenArrow => "ROpenArrow", - Self::RClosedArrow => "RClosedArrow", - Self::Slash => "Slash", - } - } - - pub fn parse(name: &str) -> Option { - Self::ALL.into_iter().find(|ending| ending.name() == name) - } -} - -/// Geometry of a new comment, in PDF user space of its page. -#[derive(Clone, Debug, PartialEq)] -pub enum Shape { - /// A sticky note whose icon's top-left corner is at `at`. - Note { - at: [f64; 2], - icon: NoteIcon, - }, - /// Text markup over quadrilaterals: `[x1 y1 x2 y2 x3 y3 x4 y4]` = top-left, top-right, - /// bottom-left, bottom-right (the order Acrobat writes, §12.5.6.10). - TextMarkup { - kind: Markup, - quads: Vec<[f64; 8]>, - }, - Rectangle { - rect: [f64; 4], - }, - Oval { - rect: [f64; 4], - }, - /// A line. An open arrow at `to` is `start: None`, `end: OpenArrow` (the Arrow tool). - Line { - from: [f64; 2], - to: [f64; 2], - start: LineEnding, - end: LineEnding, - }, - /// Freehand strokes (Draw tool). - Ink { - strokes: Vec>, - }, - /// A text box (FreeText) showing the comment's contents. - TextBox { - rect: [f64; 4], - font_size: f64, - }, - /// Fill & Sign text typed onto the page (FreeText, typewriter intent, no border). - Typewriter { - rect: [f64; 4], - font_size: f64, - }, - /// A Fill & Sign mark in `rect`. - Mark { - rect: [f64; 4], - mark: FillMark, - }, - /// A drawn signature (ink strokes). - Signature { - strokes: Vec>, - }, - /// A rubber stamp from the stamp palette; `by` is the dynamic stamps' second line. - Stamp { - rect: [f64; 4], - stamp: StampKind, - by: Option, - }, - /// A typed signature or initials: filled outlines (each contour as points normalised to - /// `rect`, 0–1, y up), drawn with the non-zero winding rule. - TypedSignature { - rect: [f64; 4], - contours: Vec>, - }, - /// A custom stamp: a picture already in the document (an image XObject, or a form XObject - /// whose `/Matrix` maps it to `size` points) filling `rect`, named `name`. - CustomStamp { - rect: [f64; 4], - name: String, - picture: ObjRef, - image: bool, - size: (f64, f64), - }, - /// A redaction mark (§12.5.6.23) over quadrilaterals (text) or one rectangle as a quad - /// (areas, pages). `overlay` is the text shown on the box once applied, drawn with `look`. - Redact { - quads: Vec<[f64; 8]>, - overlay: String, - look: OverlayLook, - }, - /// A closed polygon (Polygon tool); with `cloud`, Acrobat's Cloud tool: the same polygon - /// with a cloudy border (`/BE /S /C`, `/IT /PolygonCloud`). - Polygon { - vertices: Vec<[f64; 2]>, - cloud: bool, - }, - /// Connected lines (Polygonal Line tool). `start` is the first vertex, `end` the last. - PolyLine { - vertices: Vec<[f64; 2]>, - start: LineEnding, - end: LineEnding, - }, - /// A text callout: a text box at `rect` with a leader line from `point` (arrowhead) via - /// `knee` to the box (FreeText, `/IT /FreeTextCallout`, `/CL`). - Callout { - rect: [f64; 4], - knee: [f64; 2], - point: [f64; 2], - font_size: f64, - ending: LineEnding, - }, - /// Insert text: a caret in `rect` (its point at the top centre). - Caret { - rect: [f64; 4], - }, - /// Attach a file as a comment: an icon whose top-left corner is at `at`, holding `data` - /// (embedded) under the name `file`. - Attachment { - at: [f64; 2], - icon: AttachIcon, - file: String, - data: Vec, - }, -} - -/// The standard file attachment icons (§12.5.6.15). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub enum AttachIcon { - #[default] - PushPin, - Paperclip, - Graph, - Tag, -} - -impl AttachIcon { - pub const ALL: [AttachIcon; 4] = [AttachIcon::PushPin, AttachIcon::Paperclip, AttachIcon::Graph, AttachIcon::Tag]; - pub fn name(self) -> &'static str { - match self { - AttachIcon::PushPin => "PushPin", - AttachIcon::Paperclip => "Paperclip", - AttachIcon::Graph => "Graph", - AttachIcon::Tag => "Tag", - } - } - pub fn from_name(n: &str) -> Option { - Self::ALL.into_iter().find(|i| i.name().eq_ignore_ascii_case(n)) - } -} - -/// A rectangle `[x0 y0 x1 y1]` as a quad in Acrobat's order (top-left, top-right, bottom-left, -/// bottom-right). -pub fn rect_quad(r: [f64; 4]) -> [f64; 8] { - let [x0, y0, x1, y1] = normalize(r); - [x0, y1, x1, y1, x0, y0, x1, y0] -} - -impl Shape { - pub fn subtype(&self) -> &'static str { - match self { - Shape::Note { .. } => "Text", - Shape::TextMarkup { kind, .. } => kind.subtype(), - Shape::Rectangle { .. } => "Square", - Shape::Oval { .. } => "Circle", - Shape::Line { .. } => "Line", - Shape::Ink { .. } | Shape::Signature { .. } => "Ink", - Shape::TextBox { .. } | Shape::Typewriter { .. } | Shape::Callout { .. } => "FreeText", - Shape::Mark { .. } | Shape::Stamp { .. } | Shape::CustomStamp { .. } | Shape::TypedSignature { .. } => "Stamp", - Shape::Redact { .. } => "Redact", - Shape::Polygon { .. } => "Polygon", - Shape::PolyLine { .. } => "PolyLine", - Shape::Caret { .. } => "Caret", - Shape::Attachment { .. } => "FileAttachment", - } - } -} - -/// The standard fonts of redaction overlay text. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub enum OverlayFont { - #[default] - Helvetica, - Times, - Courier, -} - -impl OverlayFont { - pub const ALL: [OverlayFont; 3] = [OverlayFont::Helvetica, OverlayFont::Times, OverlayFont::Courier]; - - pub fn name(self) -> &'static str { - match self { - OverlayFont::Helvetica => "Helvetica", - OverlayFont::Times => "Times Roman", - OverlayFont::Courier => "Courier", - } - } - - /// The `/DA` font resource name (Acrobat's form font names). - pub fn resource(self) -> &'static str { - match self { - OverlayFont::Helvetica => "Helv", - OverlayFont::Times => "TiRo", - OverlayFont::Courier => "Cour", - } - } - - pub fn from_resource(n: &str) -> OverlayFont { - match n { - "TiRo" | "Times-Roman" | "TimesRoman" => OverlayFont::Times, - "Cour" | "Courier" => OverlayFont::Courier, - _ => OverlayFont::Helvetica, - } - } -} - -/// How redaction overlay text is drawn (Redaction Properties ▸ Appearance). -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct OverlayLook { - pub font: OverlayFont, - /// Points; 0 = auto-size to fit the area. - pub size: f64, - pub color: Rgb, - /// 0 left, 1 centre, 2 right. - pub align: u8, - /// Repeat the text to fill the area. - pub repeat: bool, -} - -impl Default for OverlayLook { - fn default() -> Self { - Self { font: OverlayFont::Helvetica, size: 0.0, color: [1.0, 0.0, 0.0], align: 1, repeat: false } - } -} - -/// How a comment looks. -#[derive(Clone, Debug, PartialEq)] -pub struct Style { - /// Stroke colour (text colour for text boxes, icon colour for notes). - pub color: Rgb, - /// 0–1 (`/CA`). - pub opacity: f64, - /// Border / stroke width in points (0 = none). - pub width: f64, - /// Interior fill for rectangles and ovals (`/IC`), background for text boxes. - pub fill: Option, -} - -impl Default for Style { - fn default() -> Self { - Self { color: [1.0, 0.82, 0.0], opacity: 1.0, width: 1.0, fill: None } - } -} - -impl Style { - /// The default look of each commenting tool. - pub fn default_for(shape: &Shape) -> Self { - let (color, width) = match shape { - Shape::Note { .. } => ([1.0, 0.82, 0.0], 1.0), - Shape::TextMarkup { kind: Markup::Highlight, .. } => ([1.0, 0.94, 0.0], 1.0), - Shape::TextMarkup { kind: Markup::Underline, .. } => ([0.0, 0.47, 0.84], 1.0), - Shape::TextMarkup { kind: Markup::StrikeOut, .. } => ([0.89, 0.13, 0.13], 1.0), - Shape::TextMarkup { kind: Markup::Squiggly, .. } => ([0.18, 0.62, 0.36], 1.0), - Shape::Rectangle { .. } | Shape::Oval { .. } | Shape::Line { .. } | Shape::Polygon { .. } | Shape::PolyLine { .. } => { - ([0.89, 0.13, 0.13], 2.0) - } - Shape::Callout { .. } => ([0.0, 0.0, 0.0], 1.0), - Shape::Caret { .. } | Shape::Attachment { .. } => ([0.0, 0.47, 0.84], 1.0), - Shape::Ink { .. } => ([0.0, 0.4, 0.87], 2.0), - Shape::TextBox { .. } | Shape::Typewriter { .. } => ([0.0, 0.0, 0.0], 0.0), - Shape::Mark { .. } => ([0.0, 0.0, 0.0], 1.5), - Shape::Signature { .. } => ([0.0, 0.0, 0.0], 1.5), - Shape::Stamp { stamp, .. } => (stamp.color(), 2.0), - Shape::CustomStamp { .. } => ([0.0, 0.0, 0.0], 0.0), - Shape::TypedSignature { .. } => ([0.0, 0.0, 0.0], 0.0), - // Red outline while marked; a black box once applied. - Shape::Redact { .. } => return Self { color: [0.89, 0.13, 0.13], opacity: 1.0, width: 1.0, fill: Some([0.0, 0.0, 0.0]) }, - }; - Self { color, opacity: 1.0, width, fill: None } - } -} - -/// A comment to add. -#[derive(Clone, Debug, PartialEq)] -pub struct NewAnnotation { - /// 0-based page index. - pub page: usize, - pub shape: Shape, - pub style: Style, - /// The comment text (`/Contents`); the text shown by a text box. - pub contents: String, - /// `/T` (shown as the comment's author). - pub author: String, -} - -/// Values stamped onto what an edit creates or changes, supplied by the caller so edits stay -/// deterministic: a PDF date (`D:…`) and a unique id for `/NM`. -#[derive(Clone, Debug, PartialEq, Default)] -pub struct Meta { - pub date: Option, - pub id: String, -} - -/// Review states a reply can set (§12.5.6.3, Table 172; Acrobat's "Set status"). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum ReviewState { - None, - Accepted, - Rejected, - Cancelled, - Completed, -} - -impl ReviewState { - pub fn name(self) -> &'static str { - match self { - ReviewState::None => "None", - ReviewState::Accepted => "Accepted", - ReviewState::Rejected => "Rejected", - ReviewState::Cancelled => "Cancelled", - ReviewState::Completed => "Completed", - } - } - - pub fn from_name(n: &str) -> Option { - [Self::None, Self::Accepted, Self::Rejected, Self::Cancelled, Self::Completed].into_iter().find(|s| s.name().eq_ignore_ascii_case(n)) - } -} - -// ── pages and /Annots ─────────────────────────────────────────────────────────────────────── - -/// The page object of each leaf page, in order. -pub fn page_refs(doc: &Document) -> Result, AnnotError> { - let root = doc.root().ok_or(AnnotError::NoPageTree)?; - let pages = doc.get(root).as_dict().and_then(|d| d.reference(b"Pages")).ok_or(AnnotError::NoPageTree)?; - Ok(PageWalk::new(doc, pages).collect()) -} - -/// Depth-first walk over the leaf pages under `top`. It keeps one cursor per open `/Kids` array, -/// never one entry per child, so a child listed many times or shared between nodes is skipped by -/// the seen check without being queued first (`pdfcraft_model::pages` walks the same way). -/// Cycles end at the seen check, a `/Kids` array object shared by several nodes is read once, -/// and at most a million nodes and four million `/Kids` entries are read. -struct PageWalk<'a> { - doc: &'a Document, - seen: std::collections::HashSet, - pending: Vec<(std::sync::Arc, usize)>, - /// `/Kids` entries read so far. - examined: usize, -} - -impl<'a> PageWalk<'a> { - fn new(doc: &'a Document, top: ObjRef) -> Self { - let top = std::sync::Arc::new(Object::Array(vec![Object::Ref(top)])); - Self { doc, seen: Default::default(), pending: vec![(top, 0)], examined: 0 } - } -} - -impl Iterator for PageWalk<'_> { - type Item = ObjRef; - - fn next(&mut self) -> Option { - while let Some((kids, next)) = self.pending.last_mut() { - let Some(kid) = kids.as_array().and_then(|a| a.get(*next)) else { - self.pending.pop(); - continue; - }; - let node = kid.as_ref(); - *next += 1; - self.examined += 1; - let Some(node) = node else { continue }; - if self.seen.len() >= 1_000_000 || self.examined > 4_000_000 { - self.pending.clear(); - return None; - } - if !self.seen.insert(node) { - continue; - } - let obj = self.doc.get(node); - let Some(d) = obj.as_dict() else { continue }; - match d.get(b"Kids") { - // A `/Kids` array object shared by several nodes is read under the first: under - // the others it would only list nodes already seen. - Some(Object::Ref(array)) if d.name(b"Type") != Some(b"Page") => { - if self.seen.insert(*array) { - self.pending.push((self.doc.get(*array), 0)); - } - } - Some(kids) if d.name(b"Type") != Some(b"Page") => self.pending.push((self.doc.resolve(kids), 0)), - _ => return Some(node), - } - } - None - } -} - -fn page_ref(doc: &Document, page: usize) -> Result { - page_refs(doc)?.get(page).copied().ok_or(AnnotError::NoSuchPage(page)) -} - -/// The entries of a page's `/Annots` (references or inline dictionaries). -fn annots(doc: &Document, page: ObjRef) -> Vec { - let obj = doc.get(page); - let Some(a) = obj.as_dict().and_then(|d| d.get(b"Annots")) else { return Vec::new() }; - let list = doc.resolve(a).as_array().cloned().unwrap_or_default(); - #[cfg(test)] - ANNOTS_ENTRIES_COPIED.with(|n| n.set(n.get() + list.len())); - list -} - -#[cfg(test)] -thread_local! { - /// Entries copied out of pages' `/Annots` arrays. - static ANNOTS_ENTRIES_COPIED: std::cell::Cell = const { std::cell::Cell::new(0) }; -} - -/// Replace a page's `/Annots`, writing into the shared array object when it is indirect. An empty -/// list removes a direct `/Annots`. -pub fn set_annots(doc: &mut Document, page: ObjRef, list: Vec) -> Result<(), pdfcraft_cos::CosError> { - let existing = doc.get(page).as_dict().and_then(|d| d.reference(b"Annots")); - match existing { - Some(r) if doc.get(r).as_array().is_some() => doc.set(r, Object::Array(list)), - _ => doc.update_dict(page, |d| { - if list.is_empty() { - d.remove(b"Annots"); - } else { - d.set(b"Annots".to_vec(), Object::Array(list)); - } - })?, - } - Ok(()) -} - -/// The annotation at `(page, index)` as an indirect object (inline dictionaries are promoted). -fn annot_ref(doc: &mut Document, page: usize, index: usize) -> Result<(ObjRef, ObjRef), AnnotError> { - let p = page_ref(doc, page)?; - let mut list = annots(doc, p); - let entry = list.get(index).cloned().ok_or(AnnotError::NoSuchAnnotation { page, index })?; - let r = match entry { - Object::Ref(r) if doc.get(r).as_dict().is_some() => r, - Object::Dict(d) => { - let r = doc.add(Object::Dict(d)); - list[index] = Object::Ref(r); - set_annots(doc, p, list)?; - r - } - _ => return Err(AnnotError::NoSuchAnnotation { page, index }), - }; - Ok((p, r)) -} - -fn annot_dict(doc: &Document, r: ObjRef) -> Dict { - doc.get(r).as_dict().cloned().unwrap_or_default() -} - -/// A Fill & Sign item: typed text, a check/cross/dot/line, or a typed, drawn or image signature. -/// -/// New items carry `/PCFillSign`. Older files are recognised from the dictionaries PdfCraft -/// already writes (`/IT /FreeTextTypeWriter`, stamp `/Name`, drawn ink whose subject is -/// "Signature"). -pub fn is_fill_sign(doc: &Document, d: &Dict) -> bool { - if matches!(d.get(b"PCFillSign").map(|o| doc.resolve(o)).as_deref(), Some(Object::Bool(true))) { - return true; - } - let subtype = d.name(b"Subtype").unwrap_or_default(); - if subtype == b"FreeText" && d.name(b"IT") == Some(b"FreeTextTypeWriter") { - return true; - } - if subtype == b"Stamp" { - if matches!(d.name(b"Name"), Some(b"PCCheck" | b"PCCross" | b"PCDot" | b"PCLine" | b"PCTypedSignature")) { - return true; - } - if matches!(d.name(b"Name"), Some(b"PCCustomSignature" | b"PCCustomInitials")) - && matches!(d.get(b"PCPictureImage").map(|o| doc.resolve(o)).as_deref(), Some(Object::Bool(true))) - { - return true; - } - } - subtype == b"Ink" && text_value(doc, d, b"Subj").as_deref() == Some("Signature") -} - -fn annotation_flags(doc: &Document, d: &Dict) -> i64 { - d.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0) -} - -/// Whether the renderer keeps the annotation upright on a turned page: the NoRotate flag, with -/// `/F` read as the renderer reads it (a real truncated, a value that isn't a `u32` no flags). -fn drawn_upright(doc: &Document, d: &Dict) -> bool { - let f = match d.get(b"F").map(|f| doc.resolve(f)).as_deref() { - Some(Object::Int(i)) => *i, - Some(Object::Real(r)) => *r as i64, - _ => 0, - }; - u32::try_from(f).is_ok_and(|f| i64::from(f) & FLAG_NO_ROTATE != 0) -} - -/// Whether any page has a Fill & Sign annotation that flatten would bake in (not hidden). -pub fn has_visible_fill_sign(doc: &Document) -> bool { - let Ok(pages) = page_refs(doc) else { return false }; - for page in pages { - for entry in annots(doc, page) { - let obj = doc.resolve(&entry); - let Some(d) = obj.as_dict() else { continue }; - let flags = annotation_flags(doc, d); - if flags & (FLAG_HIDDEN | FLAG_NO_VIEW) != 0 { - continue; - } - if is_fill_sign(doc, d) { - return true; - } - } - } - false -} - -fn fill_sign_shape(shape: &Shape) -> bool { - match shape { - Shape::Typewriter { .. } | Shape::Mark { .. } | Shape::Signature { .. } | Shape::TypedSignature { .. } => true, - Shape::CustomStamp { name, image: true, .. } => name == "Signature" || name == "Initials", - _ => false, - } -} - -/// The embedded image of a Fill & Sign image signature or initials (0-based target). -/// Other stamps have appearances that can't be represented by this image alone. -pub fn signature_image(doc: &Document, page: usize, index: usize) -> Result, AnnotError> { - let p = page_ref(doc, page)?; - let list = annots(doc, p); - let entry = list.get(index).ok_or(AnnotError::NoSuchAnnotation { page, index })?; - let obj = doc.resolve(entry); - let Some(d) = obj.as_dict() else { return Ok(None) }; - Ok((d.name(b"Subtype") == Some(b"Stamp") - && matches!(d.name(b"Name"), Some(b"PCCustomSignature" | b"PCCustomInitials")) - && matches!(d.get(b"PCPictureImage"), Some(Object::Bool(true)))) - .then(|| d.reference(b"PCPicture")) - .flatten()) -} - -/// The page `/Rotate` the picture of the image signature at `(page, index)` is drawn turned back -/// by, so it reads upright on a page shown that way: 0 for one added to an unturned page, or by -/// another app. The picture appears turned by the page's current rotation less this. -pub fn picture_rotation(doc: &Document, page: usize, index: usize) -> Result { - let p = page_ref(doc, page)?; - let list = annots(doc, p); - let entry = list.get(index).ok_or(AnnotError::NoSuchAnnotation { page, index })?; - Ok(doc.resolve(entry).as_dict().map_or(0, appearance::picture_turn)) -} - -/// Leave page `page` of (a copy of) a document showing only its annotation at `index`, without -/// the page's content or other annotations, so a render draws that annotation alone: to lay it -/// over the page while it is dragged. `false`, with `doc` unchanged, when the annotation has no -/// normal appearance (`/AP /N`) for that render to draw. -pub fn isolate_annotation(doc: &mut Document, page: usize, index: usize) -> Result { - let p = page_ref(doc, page)?; - let entry = annots(doc, p).get(index).cloned().ok_or(AnnotError::NoSuchAnnotation { page, index })?; - let has_normal = { - let obj = doc.resolve(&entry); - obj.as_dict().and_then(|d| d.get(b"AP")).is_some_and(|ap| doc.resolve(ap).as_dict().is_some_and(|ap| ap.contains(b"N"))) - }; - if !has_normal { - return Ok(false); - } - set_annots(doc, p, vec![entry])?; - doc.update_dict(p, |d| { - d.remove(b"Contents"); - })?; - Ok(true) -} - -// ── building ──────────────────────────────────────────────────────────────────────────────── - -/// Annotation flags (§12.5.3). -const FLAG_PRINT: i64 = 4; -const FLAG_NO_ZOOM: i64 = 8; -const FLAG_NO_ROTATE: i64 = 16; -const FLAG_HIDDEN: i64 = 2; -const FLAG_NO_VIEW: i64 = 32; -const FLAG_LOCKED: i64 = 128; - -/// Size of a note icon (points, unscaled by zoom). -pub const NOTE_SIZE: f64 = 20.0; - -fn num_array(v: &[f64]) -> Object { - Object::Array(v.iter().map(|x| Object::Real(*x)).collect()) -} - -fn rgb(c: Rgb) -> Object { - num_array(&c.map(|x| x.clamp(0.0, 1.0))) -} - -fn finite(v: &[f64]) -> bool { - v.iter().all(|x| x.is_finite() && x.abs() < 1e7) -} - -fn normalize(r: [f64; 4]) -> [f64; 4] { - [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])] -} - -fn bounds(points: impl Iterator) -> Option<[f64; 4]> { - points.fold(None, |acc, [x, y]| match acc { - None => Some([x, y, x, y]), - Some([a, b, c, d]) => Some([a.min(x), b.min(y), c.max(x), d.max(y)]), - }) -} - -fn grow(r: [f64; 4], by: f64) -> [f64; 4] { - [r[0] - by, r[1] - by, r[2] + by, r[3] + by] -} - -fn ending_draws(ending: LineEnding) -> bool { - ending != LineEnding::None -} - -fn ending_pair(start: LineEnding, end: LineEnding) -> Object { - Object::Array(vec![Object::name(start.name()), Object::name(end.name())]) -} - -/// Validate and compute `/Rect` for a new comment. -fn rect_for(shape: &Shape, style: &Style) -> Result<[f64; 4], AnnotError> { - let bad = |what: &str| AnnotError::Invalid(format!("invalid {what}")); - let half = style.width.max(0.0) / 2.0; - let r = match shape { - Shape::Note { at, .. } | Shape::Attachment { at, .. } => { - if !finite(at) { - return Err(bad("position")); - } - [at[0], at[1] - NOTE_SIZE, at[0] + NOTE_SIZE, at[1]] - } - Shape::TextMarkup { quads, .. } | Shape::Redact { quads, .. } => { - if quads.is_empty() || !quads.iter().all(|q| finite(q)) { - return Err(bad("text area (no quadrilaterals)")); - } - bounds(quads.iter().flat_map(|q| q.as_chunks::<2>().0.iter().map(|p| [p[0], p[1]]).collect::>())) - .ok_or_else(|| bad("text area"))? - } - Shape::Rectangle { rect } - | Shape::Oval { rect } - | Shape::TextBox { rect, .. } - | Shape::Typewriter { rect, .. } - | Shape::Stamp { rect, .. } - | Shape::TypedSignature { rect, .. } - | Shape::Mark { rect, .. } => { - let r = normalize(*rect); - if !finite(rect) || r[2] - r[0] < 1.0 || r[3] - r[1] < 1.0 { - return Err(bad("rectangle (too small)")); - } - r - } - Shape::CustomStamp { rect, .. } => { - let r = normalize(*rect); - // Image signatures may be very thin; a positive PDF appearance box still works. - if !finite(rect) || r[2] <= r[0] || r[3] <= r[1] { - return Err(bad("rectangle (empty)")); - } - r - } - Shape::Line { from, to, start, end } => { - if !finite(from) || !finite(to) || (from[0] - to[0]).hypot(from[1] - to[1]) < 1.0 { - return Err(bad("line (too short)")); - } - let pad = half + if ending_draws(*start) || ending_draws(*end) { appearance::arrow_size(style.width) } else { 0.0 }; - grow(bounds([*from, *to].into_iter()).unwrap_or_default(), pad + 1.0) - } - Shape::Ink { strokes } | Shape::Signature { strokes } => { - if strokes.iter().all(Vec::is_empty) || !strokes.iter().flatten().all(|p| finite(p)) { - return Err(bad("drawing (no points)")); - } - // Strokes of three or more points are drawn as curves, which stay within their - // points and control points. - let controls = strokes.iter().filter(|s| s.len() > 2).flat_map(|s| { - let pts: Vec<(f64, f64)> = s.iter().map(|p| (p[0], p[1])).collect(); - appearance::smooth_segments(&pts).into_iter().flat_map(|[a, b, _]| [[a.0, a.1], [b.0, b.1]]) - }); - grow(bounds(strokes.iter().flatten().copied().chain(controls)).unwrap_or_default(), half + 1.0) - } - Shape::Polygon { vertices, cloud } => { - let b = bounds(vertices.iter().copied()) - .filter(|b| vertices.len() >= 3 && vertices.iter().all(|p| finite(p)) && b[2] - b[0] >= 1.0 && b[3] - b[1] >= 1.0); - let b = b.ok_or_else(|| bad("polygon (it needs three points)"))?; - grow(b, half + 1.0 + if *cloud { 1.5 * appearance::cloud_radius(style.width) } else { 0.0 }) - } - Shape::PolyLine { vertices, start, end } => { - let b = bounds(vertices.iter().copied()) - .filter(|b| vertices.len() >= 2 && vertices.iter().all(|p| finite(p)) && (b[2] - b[0]).max(b[3] - b[1]) >= 1.0); - let extra = if ending_draws(*start) || ending_draws(*end) { appearance::arrow_size(style.width) } else { 0.0 }; - grow(b.ok_or_else(|| bad("connected lines (they need two points)"))?, half + 1.0 + extra) - } - Shape::Callout { rect, knee, point, .. } => { - let r = normalize(*rect); - if !finite(rect) || !finite(knee) || !finite(point) || r[2] - r[0] < 1.0 || r[3] - r[1] < 1.0 { - return Err(bad("callout")); - } - let pad = half + appearance::arrow_size(style.width) + 1.0; - let lead = grow(bounds([*knee, *point].into_iter()).unwrap_or_default(), pad); - [r[0].min(lead[0]), r[1].min(lead[1]), r[2].max(lead[2]), r[3].max(lead[3])] - } - Shape::Caret { rect } => { - let r = normalize(*rect); - if !finite(rect) || r[2] - r[0] < 1.0 || r[3] - r[1] < 1.0 { - return Err(bad("caret")); - } - r - } - }; - Ok(r) -} - -fn base_dict(subtype: &str, rect: [f64; 4], page: ObjRef, contents: &str, author: &str, meta: &Meta) -> Dict { - let mut d = Dict::new(); - d.set(b"Type".to_vec(), Object::name("Annot")); - d.set(b"Subtype".to_vec(), Object::name(subtype)); - d.set(b"Rect".to_vec(), num_array(&rect)); - d.set(b"Contents".to_vec(), PdfString::text(contents)); - if !author.is_empty() { - d.set(b"T".to_vec(), PdfString::text(author)); - } - if let Some(date) = &meta.date { - d.set(b"M".to_vec(), PdfString::literal(date.as_bytes().to_vec())); - d.set(b"CreationDate".to_vec(), PdfString::literal(date.as_bytes().to_vec())); - } - if !meta.id.is_empty() { - d.set(b"NM".to_vec(), PdfString::text(&meta.id)); - } - d.set(b"P".to_vec(), Object::Ref(page)); - d.set(b"F".to_vec(), Object::Int(FLAG_PRINT)); - d -} - -/// Acrobat's `/Subj` for each tool (shown as the comment type in other viewers). -fn subject(shape: &Shape) -> &'static str { - match shape { - Shape::Note { .. } => "Sticky Note", - Shape::TextMarkup { kind: Markup::Highlight, .. } => "Highlight", - Shape::TextMarkup { kind: Markup::Underline, .. } => "Underline", - Shape::TextMarkup { kind: Markup::StrikeOut, .. } => "Strikethrough", - Shape::TextMarkup { kind: Markup::Squiggly, .. } => "Squiggly", - Shape::Rectangle { .. } => "Rectangle", - Shape::Oval { .. } => "Oval", - Shape::Line { start: LineEnding::None, end: LineEnding::OpenArrow, .. } => "Arrow", - Shape::Line { .. } => "Line", - Shape::Ink { .. } => "Pencil", - Shape::TextBox { .. } => "Text Box", - Shape::Typewriter { .. } => "Typewriter", - Shape::Mark { mark, .. } => mark.label(), - Shape::Signature { .. } => "Signature", - Shape::Redact { .. } => "Redact", - Shape::Stamp { stamp, .. } => match stamp.group() { - StampGroup::Dynamic => "Dynamic stamp", - StampGroup::SignHere => "Sign Here", - StampGroup::StandardBusiness => "Stamp", - }, - Shape::CustomStamp { .. } => "Stamp", - Shape::TypedSignature { .. } => "Signature", - Shape::Polygon { cloud: true, .. } => "Cloud", - Shape::Polygon { .. } => "Polygon", - Shape::PolyLine { .. } => "Polygonal Line", - Shape::Callout { .. } => "Callout", - Shape::Caret { .. } => "Inserted Text", - Shape::Attachment { .. } => "File Attachment", - } -} - -/// Where a callout's leader line meets its text box: the middle of the side facing `knee`. -pub fn callout_attach(rect: [f64; 4], knee: [f64; 2]) -> [f64; 2] { - let [x0, y0, x1, y1] = normalize(rect); - let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0); - if knee[0] < x0 { - [x0, cy] - } else if knee[0] > x1 { - [x1, cy] - } else if knee[1] > y1 { - [cx, y1] - } else { - [cx, y0] - } -} - -/// Add a comment; returns its index in the page's `/Annots`. -pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> Result { - let page = page_ref(doc, new.page)?; - let style = &new.style; - if !finite(&style.color) || !style.opacity.is_finite() || !style.width.is_finite() { - return Err(AnnotError::Invalid("invalid style".into())); - } - let rect = rect_for(&new.shape, style)?; - let mut d = base_dict(new.shape.subtype(), rect, page, &new.contents, &new.author, meta); - d.set(b"Subj".to_vec(), PdfString::text(subject(&new.shape))); - let opacity = style.opacity.clamp(0.0, 1.0); - if opacity < 1.0 { - d.set(b"CA".to_vec(), Object::Real(opacity)); - } - let border = |d: &mut Dict| { - let mut bs = Dict::new(); - bs.set(b"W".to_vec(), Object::Real(style.width.max(0.0))); - bs.set(b"S".to_vec(), Object::name("S")); - d.set(b"BS".to_vec(), Object::Dict(bs)); - }; - let mut popup = None; - match &new.shape { - Shape::Note { icon, .. } => { - d.set(b"C".to_vec(), rgb(style.color)); - d.set(b"Name".to_vec(), Object::name(icon.name())); - d.set(b"F".to_vec(), Object::Int(FLAG_PRINT | FLAG_NO_ZOOM | FLAG_NO_ROTATE)); - d.set(b"Open".to_vec(), Object::Bool(false)); - popup = Some([rect[2] + 10.0, rect[3] - 120.0, rect[2] + 210.0, rect[3]]); - } - Shape::TextMarkup { quads, .. } => { - d.set(b"C".to_vec(), rgb(style.color)); - d.set(b"QuadPoints".to_vec(), num_array(&quads.concat())); - } - Shape::Redact { quads, overlay, look } => { - d.set(b"C".to_vec(), rgb(style.color)); - d.set(b"IC".to_vec(), rgb(style.fill.unwrap_or([0.0, 0.0, 0.0]))); - d.set(b"QuadPoints".to_vec(), num_array(&quads.concat())); - if !overlay.is_empty() { - d.set(b"OverlayText".to_vec(), PdfString::text(overlay)); - let [r, g, b] = look.color.map(|x| x.clamp(0.0, 1.0)); - let size = if look.size > 0.0 { look.size.min(400.0) } else { 0.0 }; - d.set( - b"DA".to_vec(), - PdfString::literal(format!("{} {} {} rg /{} {} Tf", n(r), n(g), n(b), look.font.resource(), n(size)).into_bytes()), - ); - d.set(b"Q".to_vec(), Object::Int(look.align.min(2) as i64)); - if look.repeat { - d.set(b"Repeat".to_vec(), Object::Bool(true)); - } - } - } - Shape::Rectangle { .. } | Shape::Oval { .. } => { - d.set(b"C".to_vec(), rgb(style.color)); - if let Some(f) = style.fill { - d.set(b"IC".to_vec(), rgb(f)); - } - border(&mut d); - } - Shape::Line { from, to, start, end } => { - d.set(b"C".to_vec(), rgb(style.color)); - d.set(b"L".to_vec(), num_array(&[from[0], from[1], to[0], to[1]])); - if ending_draws(*start) || ending_draws(*end) { - d.set(b"LE".to_vec(), ending_pair(*start, *end)); - } - border(&mut d); - } - Shape::Mark { mark, .. } => { - d.set(b"C".to_vec(), rgb(style.color)); - d.set(b"Name".to_vec(), Object::name(mark.name())); - border(&mut d); - // Drawn upright for the page as displayed, as a stamp is (#2392). - let turn = pdfcraft_model::pages(doc).get(new.page).map_or(0, |p| p.rotation(doc)); - if matches!(turn, 90 | 180 | 270) { - d.set(b"PCStampRotate".to_vec(), Object::Int(turn)); - } - } - Shape::Stamp { stamp, by, .. } => { - d.set(b"C".to_vec(), rgb(style.color)); - d.set(b"Name".to_vec(), Object::name(stamp.name())); - // Marks the stamp as drawn by PdfCraft: other stamps with standard names keep - // their own artwork. - d.set(b"PCStamp".to_vec(), Object::Bool(true)); - if let Some(b) = by { - d.set(b"PCByLine".to_vec(), PdfString::text(b)); - } - // The label is drawn for the page as displayed. A later page rotation is a new edit. - let turn = pdfcraft_model::pages(doc).get(new.page).map_or(0, |p| p.rotation(doc)); - if matches!(turn, 90 | 180 | 270) { - d.set(b"PCStampRotate".to_vec(), Object::Int(turn)); - } - } - Shape::TypedSignature { contours, .. } => { - d.set(b"C".to_vec(), rgb(style.color)); - d.set(b"Name".to_vec(), Object::name("PCTypedSignature")); - let list = contours.iter().filter(|c| c.len() > 2).map(|c| num_array(&c.iter().flat_map(|p| [p[0], p[1]]).collect::>())).collect(); - d.set(b"PCOutline".to_vec(), Object::Array(list)); - } - Shape::CustomStamp { name, picture, image, size, .. } => { - let clean: String = name.chars().filter(char::is_ascii_alphanumeric).collect(); - d.set(b"Name".to_vec(), Object::name(&format!("PCCustom{clean}"))); - d.set(b"PCPicture".to_vec(), Object::Ref(*picture)); - d.set(b"PCPictureImage".to_vec(), Object::Bool(*image)); - d.set(b"PCPictureSize".to_vec(), num_array(&[size.0, size.1])); - // An image signature's rectangle is in user space; on a turned page its appearance is - // turned back (see `appearance::build`), so it reads upright as displayed. - if *image && matches!(clean.as_str(), "Signature" | "Initials") { - let turn = pdfcraft_model::pages(doc).get(new.page).map_or(0, |p| p.rotation(doc)); - if turn != 0 { - d.set(b"PCPictureRotate".to_vec(), Object::Int(turn)); - } - } - } - Shape::Ink { strokes } | Shape::Signature { strokes } => { - d.set(b"C".to_vec(), rgb(style.color)); - let list = strokes.iter().filter(|s| !s.is_empty()).map(|s| num_array(&s.concat())).collect(); - d.set(b"InkList".to_vec(), Object::Array(list)); - border(&mut d); - } - Shape::Polygon { vertices, cloud } => { - d.set(b"C".to_vec(), rgb(style.color)); - if let Some(f) = style.fill { - d.set(b"IC".to_vec(), rgb(f)); - } - d.set(b"Vertices".to_vec(), num_array(&vertices.concat())); - if *cloud { - d.set(b"IT".to_vec(), Object::name("PolygonCloud")); - let mut be = Dict::new(); - be.set(b"S".to_vec(), Object::name("C")); - be.set(b"I".to_vec(), Object::Int(1)); - d.set(b"BE".to_vec(), Object::Dict(be)); - } - border(&mut d); - } - Shape::PolyLine { vertices, start, end } => { - d.set(b"C".to_vec(), rgb(style.color)); - d.set(b"Vertices".to_vec(), num_array(&vertices.concat())); - if ending_draws(*start) || ending_draws(*end) { - d.set(b"LE".to_vec(), ending_pair(*start, *end)); - } - border(&mut d); - } - Shape::Caret { .. } => { - d.set(b"C".to_vec(), rgb(style.color)); - d.set(b"Sy".to_vec(), Object::name("None")); - } - Shape::Attachment { icon, file, data, .. } => { - if file.trim().is_empty() { - return Err(AnnotError::Invalid("the attached file needs a name".into())); - } - d.set(b"C".to_vec(), rgb(style.color)); - d.set(b"Name".to_vec(), Object::name(icon.name())); - d.set(b"F".to_vec(), Object::Int(FLAG_PRINT | FLAG_NO_ZOOM | FLAG_NO_ROTATE)); - // The file: an embedded file stream inside a file specification (§7.11.4). - let mut params = Dict::new(); - params.set(b"Size".to_vec(), Object::Int(data.len() as i64)); - if let Some(date) = &meta.date { - params.set(b"ModDate".to_vec(), PdfString::literal(date.as_bytes().to_vec())); - } - let mut ef = Dict::new(); - ef.set(b"Type".to_vec(), Object::name("EmbeddedFile")); - ef.set(b"Params".to_vec(), Object::Dict(params)); - let ef = doc.add(Object::Stream(pdfcraft_cos::Stream::flate(ef, data))); - let mut efd = Dict::new(); - efd.set(b"F".to_vec(), Object::Ref(ef)); - efd.set(b"UF".to_vec(), Object::Ref(ef)); - let mut fs = Dict::new(); - fs.set(b"Type".to_vec(), Object::name("Filespec")); - fs.set(b"F".to_vec(), PdfString::text(file.trim())); - fs.set(b"UF".to_vec(), PdfString::text(file.trim())); - fs.set(b"EF".to_vec(), Object::Dict(efd)); - d.set(b"FS".to_vec(), Object::Ref(doc.add(Object::Dict(fs)))); - if new.contents.is_empty() { - d.set(b"Contents".to_vec(), PdfString::text(file.trim())); - } - } - Shape::Callout { rect: tb, knee, point, ending, .. } => { - let tb = normalize(*tb); - let attach = callout_attach(tb, *knee); - d.set(b"IT".to_vec(), Object::name("FreeTextCallout")); - d.set(b"CL".to_vec(), num_array(&[point[0], point[1], knee[0], knee[1], attach[0], attach[1]])); - d.set(b"LE".to_vec(), Object::name(ending.name())); - // The text box inside /Rect (§12.5.6.6 /RD). - d.set(b"RD".to_vec(), num_array(&[tb[0] - rect[0], tb[1] - rect[1], rect[2] - tb[2], rect[3] - tb[3]])); - } - _ => {} - } - match &new.shape { - Shape::TextBox { font_size, .. } | Shape::Typewriter { font_size, .. } | Shape::Callout { font_size, .. } => { - if matches!(new.shape, Shape::Typewriter { .. }) { - d.set(b"IT".to_vec(), Object::name("FreeTextTypeWriter")); - } - let size = if font_size.is_finite() && *font_size > 0.0 { font_size.clamp(1.0, 400.0) } else { 12.0 }; - let [r, g, b] = style.color.map(|x| x.clamp(0.0, 1.0)); - d.set(b"DA".to_vec(), PdfString::literal(format!("{} {} {} rg /Helv {} Tf", n(r), n(g), n(b), n(size)).into_bytes())); - d.set(b"Q".to_vec(), Object::Int(0)); - if let Some(f) = style.fill { - d.set(b"C".to_vec(), rgb(f)); - } - border(&mut d); - rich_text(&mut d); - // The text is laid out for the page as displayed, like a stamp's label. - if !matches!(new.shape, Shape::Callout { .. }) { - let turn = pdfcraft_model::pages(doc).get(new.page).map_or(0, |p| p.rotation(doc)); - if matches!(turn, 90 | 180 | 270) { - d.set(b"PCTextRotate".to_vec(), Object::Int(turn)); - } - } - } - _ => {} - } - if fill_sign_shape(&new.shape) { - // Survives a later subject edit, so flatten-on-save still finds the mark. - d.set(b"PCFillSign".to_vec(), Object::Bool(true)); - } - // A redaction's overlay text becomes page content when it is applied, where `?` would be there - // for good: refused before anything is added. A comment's own text is stored as typed and - // drawn with `?` for what the standard fonts can't draw (marked, see `draw_appearance`). - if d.name(b"Subtype") == Some(b"Redact") - && let Some((what, c)) = appearance::undrawable_text(&d) - { - return Err(AnnotError::Undrawable(pdfcraft_fonts::undrawable_message(what, c))); - } - let r = doc.add(Object::Dict(d.clone())); - draw_appearance(doc, r)?; - let mut list = annots(doc, page); - let index = list.len(); - list.push(Object::Ref(r)); - if let Some(pr) = popup { - let mut p = Dict::new(); - p.set(b"Type".to_vec(), Object::name("Annot")); - p.set(b"Subtype".to_vec(), Object::name("Popup")); - p.set(b"Rect".to_vec(), num_array(&pr)); - p.set(b"Parent".to_vec(), Object::Ref(r)); - p.set(b"Open".to_vec(), Object::Bool(false)); - p.set(b"F".to_vec(), Object::Int(FLAG_PRINT | FLAG_NO_ZOOM | FLAG_NO_ROTATE)); - let pref = doc.add(Object::Dict(p)); - doc.update_dict(r, |d| d.set(b"Popup".to_vec(), Object::Ref(pref)))?; - list.push(Object::Ref(pref)); - } - set_annots(doc, page, list)?; - Ok(index) -} - -/// Counterrotate a newly placed picture stamp's appearance into displayed-page axes. -/// The picture may be an image or a PDF page. The normal appearance's transformed bounding -/// box is fitted to `/Rect` by PDF viewers. -pub fn orient_image_stamp(doc: &mut Document, page: usize, index: usize, rotation: i64) -> Result<(), AnnotError> { - let matrix = match rotation { - 90 => [0.0, 1.0, -1.0, 0.0, 0.0, 0.0], - 180 => [-1.0, 0.0, 0.0, -1.0, 0.0, 0.0], - 270 => [0.0, -1.0, 1.0, 0.0, 0.0, 0.0], - _ => return Ok(()), - }; - let (_, r) = annot_ref(doc, page, index)?; - let d = annot_dict(doc, r); - if d.name(b"Subtype") != Some(b"Stamp") || d.get(b"PCPicture").is_none() { - return Err(AnnotError::Invalid("only a picture stamp can be oriented".into())); - } - let normal = d - .get(b"AP") - .map(|ap| doc.resolve(ap)) - .and_then(|ap| ap.as_dict().and_then(|ap| ap.reference(b"N"))) - .ok_or_else(|| AnnotError::Invalid("the stamp has no normal appearance".into()))?; - let Object::Stream(mut stream) = doc.get(normal).as_ref().clone() else { - return Err(AnnotError::Invalid("the stamp's normal appearance is not a stream".into())); - }; - stream.dict.set(b"Matrix".to_vec(), num_array(&matrix)); - doc.set(normal, Object::Stream(stream)); - Ok(()) -} - -/// A comment another program drew, whose text the standard fonts can't draw. Redrawing it would -/// replace those characters with `?`, so the edit is refused before the dictionary changes. -fn refuse_foreign_text(doc: &Document, d: &Dict) -> Result<(), AnnotError> { - let drawn_elsewhere = - d.get(b"AP").is_some_and(|ap| doc.resolve(ap).as_dict().is_some_and(|ap| ap.contains(b"N"))) && pdfcraft_fonts::undrawn_mark(d).is_none(); - if drawn_elsewhere && let Some((what, c)) = appearance::undrawable_text(d) { - return Err(AnnotError::Undrawable(format!( - "PdfCraft can't redraw this comment: the standard fonts can't draw \"{}\" (U+{:04X}) in {what}, which would then show as ? (the comment was left as it is)", - c.escape_debug(), - u32::from(c) - ))); - } - Ok(()) -} - -/// (Re)generate `/AP /N` for the annotation `r` from its dictionary, for an edit that keeps its -/// text (a restyle, a new size or colour). -/// -/// A comment whose text the standard fonts can't draw, with an appearance another program drew -/// (with a font that has those characters), keeps that appearance: redrawing it would put `?` in -/// their place, so the edit is refused (`AnnotError::Undrawable`). One PdfCraft drew with `?` -/// (marked `pdfcraft_fonts::UNDRAWN_MARK`) is redrawn. -pub fn set_appearance(doc: &mut Document, r: ObjRef) -> Result<(), AnnotError> { - refuse_foreign_text(doc, &annot_dict(doc, r))?; - draw_appearance(doc, r) -} - -/// Put `before` back when redrawing fails, so a refused edit does not keep the new rectangle or style. -fn redraw_or_restore(doc: &mut Document, r: ObjRef, before: Dict) -> Result<(), AnnotError> { - if let Err(e) = set_appearance(doc, r) { - doc.set(r, Object::Dict(before)); - return Err(e); - } - Ok(()) -} - -/// Draw `r`'s normal appearance from its dictionary, whatever its text: characters the standard -/// fonts can't draw come out as `?`, and the annotation is marked (`pdfcraft_fonts::UNDRAWN_MARK`, -/// cleared when all of it can be drawn) so the edit is reported and flattening won't make that -/// `?` permanent. For new text (a new comment, changed contents), as Acrobat keeps text its font -/// can't show, and for display-only copies. -fn draw_appearance(doc: &mut Document, r: ObjRef) -> Result<(), AnnotError> { - let d = annot_dict(doc, r); - let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned(); - let Some(stream) = build_appearance(doc, &d) else { return Err(AnnotError::Unsupported(subtype)) }; - apply_appearance(doc, r, &d, stream) -} - -/// Draw the appearance for an annotation dictionary: the expensive half of [`set_appearance`]. -/// Pure from the dictionary and the objects it points at, so [`AppearanceCache`] can reuse it. -fn build_appearance(doc: &Document, d: &Dict) -> Option { - let mut stream = appearance::build(d)?; - // Picture-stamp restyling must retain the placement's page-axis correction. - if d.get(b"PCPicture").is_some() { - let matrix = d.get(b"AP").and_then(|ap| { - let ap = doc.resolve(ap); - let normal = doc.resolve(ap.as_dict()?.get(b"N")?); - let Object::Stream(normal) = normal.as_ref() else { return None }; - normal.dict.get(b"Matrix").cloned() - }); - if let Some(matrix) = matrix { - stream.dict.set(b"Matrix".to_vec(), matrix); - } - } - Some(stream) -} - -/// Store a built appearance on the annotation: the cheap half of [`set_appearance`]. -fn apply_appearance(doc: &mut Document, r: ObjRef, d: &Dict, stream: Stream) -> Result<(), AnnotError> { - // Characters the standard fonts can't draw came out as `?`: mark the annotation with the first. - let missing = appearance::undrawable_text(d).map(|(_, c)| c); - let ap = doc.add(Object::Stream(stream)); - // A copy (a shared /AP is left alone) that keeps unknown entries; the old down and rollover - // appearances would show the previous look on press or hover, so they go with the old /N. - let mut apd = d.get(b"AP").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()).unwrap_or_default(); - apd.remove(b"D"); - apd.remove(b"R"); - apd.set(b"N".to_vec(), Object::Ref(ap)); - // A note icon has a fixed size (`NOTE_SIZE`); viewers draw it at the upper-left corner of `/Rect`. - // The appearance's box is mapped onto `/Rect`, so any other `/Rect` (a large one, or one with - // no width, as LaTeX `pdfcomment` writes) would stretch the icon or shrink it to nothing. - let icon_rect = matches!(d.name(b"Subtype"), Some(b"Text" | b"FileAttachment")) - .then(|| d.get(b"Rect")?.as_array()?.iter().map(|o| o.as_f64()).collect::>>()) - .flatten() - .filter(|q| q.len() == 4 && finite(q)) - .map(|q| { - let (x, top) = (q[0].min(q[2]), q[1].max(q[3])); - [x, top - NOTE_SIZE, x + NOTE_SIZE, top] - }); - doc.update_dict(r, |d| { - d.set(b"AP".to_vec(), Object::Dict(apd)); - if let Some(rect) = icon_rect { - d.set(b"Rect".to_vec(), num_array(&rect)); - } - d.remove(b"AS"); - pdfcraft_fonts::set_undrawn_mark(d, missing); - })?; - Ok(()) -} - -/// Display appearances built for annotations without any, reused while the annotation -/// dictionaries they were drawn from are unchanged. -/// -/// A content edit (moving a paragraph, retyping text, …) leaves every annotation dictionary -/// alone, but the display copy is rebuilt from the edited document on every refresh: without -/// this cache each refresh redrew every appearance (27k on one CAD file, ~0.7 s per edit -/// commit). Entries are keyed by annotation reference; object numbers are never reused within -/// a document lineage, so an entry stays valid until an annotation edit runs. Callers clear -/// the cache on any refresh that may change annotations (comments, structural and full -/// refreshes) and keep it for content-only ones. -#[derive(Clone, Debug, Default)] -pub struct AppearanceCache { - entries: std::collections::HashMap, - bytes: usize, - hits: u64, - builds: u64, -} - -impl AppearanceCache { - /// Cached appearance data restarts past this instead of growing without bound. - const MAX_BYTES: usize = 64 << 20; - - /// Forget every entry (an annotation edit may have changed the dictionaries). - pub fn clear(&mut self) { - *self = Self::default(); - } - - fn get(&mut self, r: ObjRef) -> Option { - let stream = self.entries.get(&r)?.clone(); - self.hits += 1; - Some(stream) - } - - fn insert(&mut self, r: ObjRef, stream: &Stream) { - self.builds += 1; - if self.entries.len() >= 1_000_000 { - self.clear(); - } - if self.bytes.saturating_add(stream.raw.len()) <= Self::MAX_BYTES { - self.bytes += stream.raw.len(); - self.entries.insert(r, stream.clone()); - } - // Else over budget: keep serving the entries already cached instead of growing. - } - - /// Cache hits so far (reused appearances); for tests. - pub fn hits(&self) -> u64 { - self.hits - } - - /// Appearances built so far; for tests. - pub fn builds(&self) -> u64 { - self.builds - } -} - -/// A copy of `doc` in which every annotation without a normal appearance (`/AP /N`) has the one -/// [`appearance::build`] draws from its dictionary, for displaying the document (`None` when no -/// annotation needs one). Many files carry comments without appearances (FreeText, Ink, notes, -/// stamps, …) that viewers draw from the dictionary. `doc` is not changed: saving writes the file -/// as it was. Links, form fields and pop-ups keep their own handling. -pub fn with_missing_appearances(doc: &Document) -> Option { - with_missing_appearances_cached(doc, &mut AppearanceCache::default()) -} - -/// A copy of `doc` with [`AppearanceCache`] reuse across refreshes: annotations whose -/// dictionary is unchanged since the cached build keep their stored appearance instead of -/// redrawing it. See [`AppearanceCache`] for when entries stay valid. -pub fn with_missing_appearances_cached(doc: &Document, cache: &mut AppearanceCache) -> Option { - let mut copy: Option = None; - // An annotation listed more than once, on one page or several, is drawn once. `has_normal` - // reads `doc`, which never gets the appearance, so every repeat used to add another stream to - // the copy, and the display bytes grew with the number of listings. - let mut seen = std::collections::HashSet::new(); - for page in page_refs(doc).ok()? { - for r in annots(doc, page).iter().filter_map(Object::as_ref) { - if !seen.insert(r) { - continue; - } - let d = annot_dict(doc, r); - if matches!(d.name(b"Subtype"), None | Some(b"Link" | b"Widget" | b"Popup")) { - continue; - } - let has_normal = d.get(b"AP").is_some_and(|ap| doc.resolve(ap).as_dict().is_some_and(|ap| ap.contains(b"N"))); - if has_normal { - continue; - } - if let Some(stream) = cache.get(r) { - // Fails only when `r` isn't a dictionary, which the cached build ruled out. - apply_appearance(copy.get_or_insert_with(|| doc.clone()), r, &d, stream).ok(); - continue; - } - // Another program's stamp gets a drawing for display only (ISO 32000-2 Table 184). Its - // `/AP`, if any, has no `/N` to keep here. - let other_stamp = || { - let mut bare = d.clone(); - bare.remove(b"AP"); - appearance::display_stamp(&bare) - }; - let Some(stream) = build_appearance(doc, &d).or_else(other_stamp) else { continue }; - cache.insert(r, &stream); - // Fails only when `r` isn't a dictionary, which the build just ruled out. - apply_appearance(copy.get_or_insert_with(|| doc.clone()), r, &d, stream).ok(); - } - } - copy -} - -/// Format a number for content streams and DA strings. -pub(crate) fn n(v: f64) -> String { - let s = format!("{:.3}", if v.abs() < 5e-4 { 0.0 } else { v }); - let s = s.trim_end_matches('0').trim_end_matches('.'); - if s == "-0" { "0".into() } else { s.into() } -} - -// ── edits ─────────────────────────────────────────────────────────────────────────────────── - -/// A text box's rich text (§12.7.4.3): `/DS` (default style) and `/RC` (XHTML) written from its -/// plain `/Contents`, `/DA` and `/Q`, so viewers that edit rich text show the same thing. -fn rich_text(d: &mut Dict) { - let text = d.get(b"Contents").and_then(|c| c.as_string()).map(PdfString::to_text).unwrap_or_default(); - let (col, size) = appearance::parse_da(d); - let hex = format!( - "#{:02X}{:02X}{:02X}", - (col[0].clamp(0.0, 1.0) * 255.0).round() as u8, - (col[1].clamp(0.0, 1.0) * 255.0).round() as u8, - (col[2].clamp(0.0, 1.0) * 255.0).round() as u8 - ); - let align = match d.get(b"Q").and_then(Object::as_int) { - Some(1) => "center", - Some(2) => "right", - _ => "left", - }; - let esc = |s: &str| s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """); - let body: String = text.split('\n').map(|line| format!("

{}

", esc(line.trim_end_matches('\r')))).collect(); - let style = format!( - "font-size:{}pt;text-align:{align};color:{hex};font-weight:normal;font-style:normal;font-family:Helvetica;font-stretch:normal", - n(size) - ); - let rc = format!( - "{body}" - ); - d.set(b"DS".to_vec(), PdfString::text(&format!("font: Helvetica {}pt; text-align:{align}; color:{hex}", n(size)))); - d.set(b"RC".to_vec(), PdfString::text(&rc)); -} - -fn touch(d: &mut Dict, meta: &Meta) { - if let Some(date) = &meta.date { - d.set(b"M".to_vec(), PdfString::literal(date.as_bytes().to_vec())); - } -} - -/// Delete a comment together with its pop-up and its replies (and theirs), as Acrobat does. -pub fn delete_annotation(doc: &mut Document, page: usize, index: usize) -> Result<(), AnnotError> { - let p = page_ref(doc, page)?; - let list = annots(doc, p); - let target = list.get(index).cloned().ok_or(AnnotError::NoSuchAnnotation { page, index })?; - if let Some(r) = target.as_ref() { - unlocked(doc, r)?; - } - // An inline comment has no reference to collect; the pop-up it names still goes. - let seed = target.as_ref().or_else(|| target.as_dict().and_then(|d| d.reference(b"Popup"))); - let doomed = with_replies_and_popups(doc, &list, seed); - let kept: Vec = list.into_iter().enumerate().filter(|(i, e)| *i != index && !goes_with(e, &doomed)).map(|(_, e)| e).collect(); - Ok(set_annots(doc, p, kept)?) -} - -/// Whether the `/Annots` entry `entry` goes with `going` (from [`with_replies_and_popups`]): it is -/// one of them, or it is written inline (a file can, though the standard asks for references) and -/// replies to (`/IRT`) or pops up for (`/Parent`) one of them. An inline entry has no reference -/// for the walk to collect, so it is matched here. -pub fn goes_with(entry: &Object, going: &HashSet) -> bool { - match entry.as_ref() { - Some(r) => going.contains(&r), - None => entry.as_dict().is_some_and(|d| [d.reference(b"IRT"), d.reference(b"Parent")].into_iter().flatten().any(|r| going.contains(&r))), - } -} - -/// `doomed` and what goes with it, as Acrobat deletes a comment: the entries of `list` (one or -/// more pages' `/Annots`) that reply to (`/IRT`) or pop up for (`/Parent`) anything going, their -/// own replies, and the pop-ups going annotations name (`/Popup`). An inline reply has no -/// reference to collect, but the pop-up it names is. Each annotation is visited once, so a long or -/// cyclic chain costs no more than its length. Filter a list with [`goes_with`], which also -/// matches inline entries. -pub fn with_replies_and_popups(doc: &Document, list: &[Object], doomed: impl IntoIterator) -> HashSet { - let mut hanging: HashMap> = HashMap::new(); - for e in list { - let obj = doc.resolve(e); - let Some(d) = obj.as_dict() else { continue }; - // What goes with the annotation this entry hangs off: the entry, or for an inline one the - // pop-up it names. - let Some(goes) = e.as_ref().or_else(|| d.reference(b"Popup")) else { continue }; - for on in [d.reference(b"IRT"), d.reference(b"Parent")].into_iter().flatten() { - hanging.entry(on).or_default().push(goes); - } - } - let mut going = HashSet::new(); - let mut todo: Vec = doomed.into_iter().collect(); - while let Some(r) = todo.pop() { - if !going.insert(r) { - continue; - } - todo.extend(hanging.get(&r).into_iter().flatten()); - todo.extend(doc.get(r).as_dict().and_then(|d| d.reference(b"Popup"))); - } - going -} - -/// Change a comment's text. Text boxes are redrawn to show it, and their rectangle follows the -/// new text: the wrap width and top edge stay, the height fits the wrapped lines. -pub fn set_contents(doc: &mut Document, page: usize, index: usize, text: &str, meta: &Meta) -> Result<(), AnnotError> { - let (_, r) = annot_ref(doc, page, index)?; - let free_text = annot_dict(doc, r).name(b"Subtype") == Some(b"FreeText"); - doc.update_dict(r, |d| { - d.set(b"Contents".to_vec(), PdfString::text(text)); - // The rich-text version follows the new plain text (text boxes), or would contradict it. - if free_text { - rich_text(d); - } else { - d.remove(b"RC"); - } - touch(d, meta); - })?; - if free_text { - // The box grows and shrinks with the text instead of clipping it. A locked box keeps its - // text editable (as in Acrobat), so the re-fit ignores the lock. New text is drawn even - // where the standard fonts can't draw some of it (marked and reported, see - // `draw_appearance`). - match fitted_box(doc, r, text) { - Some(rect) => apply_text_box(doc, r, rect, meta, true)?, - None => draw_appearance(doc, r)?, - } - } - Ok(()) -} - -/// The rectangle a FreeText annotation's text box needs for `text`: the current wrap width and -/// top edge stay and the height fits the wrapped lines, as at creation. `None` keeps the old -/// rectangle (the box can't be measured, so only the appearance is redrawn). -fn fitted_box(doc: &Document, r: ObjRef, text: &str) -> Option<[f64; 4]> { - let d = annot_dict(doc, r); - let nums = |key: &[u8]| -> Option> { d.get(key)?.as_array()?.iter().map(|o| o.as_f64()).collect() }; - let (_, size) = appearance::parse_da(&d); - let pad = 2.0 + border_width_of(&d); - let rect = nums(b"Rect").filter(|v| v.len() == 4 && v.iter().all(|x| x.is_finite()))?; - let rect = [rect[0], rect[1], rect[2], rect[3]]; - // A callout re-fits its text box (`/Rect` inset by `/RD`); the leader line keeps its place. - let tb = if d.contains(b"CL") { - let rd = nums(b"RD").filter(|v| v.len() == 4 && v.iter().all(|x| *x >= 0.0))?; - [rect[0] + rd[0], rect[1] + rd[1], rect[2] - rd[2], rect[3] - rd[3]] - } else { - rect - }; - let (x0, top) = (tb[0].min(tb[2]), tb[1].max(tb[3])); - let w = tb[2] - tb[0]; - let w = if w.is_finite() && w >= 1.0 { - w - } else { - // No usable old width: fall back to the creation-time width (at most 300 pt). - let longest = text.lines().map(|l| appearance::text_width(l, size)).fold(0.0, f64::max); - (longest + 2.0 * pad + 4.0).clamp(40.0, 300.0) - }; - let lines = appearance::wrap(text, size, (w - 2.0 * pad).max(1.0)).len().max(1) as f64; - Some([x0, top - lines * size * 1.2 - 2.0 * pad - 2.0, x0 + w, top]) -} - -/// Reply to a comment; returns the reply's index in the page's `/Annots`. -/// -/// A reply is a text annotation with `/IRT` pointing at its parent (§12.5.6.2). It gets the -/// parent's rectangle and an empty appearance, so it shows in comment lists but never paints a -/// second icon on the page. -pub fn add_reply(doc: &mut Document, page: usize, index: usize, text: &str, author: &str, meta: &Meta) -> Result { - reply(doc, page, index, text, author, meta, None) -} - -/// Set a comment's review status (Acrobat: "Set status ▸ Accepted"…). Like Acrobat this adds a -/// state reply (`/State`, `/StateModel /Review`) by `author`; the latest one wins. -pub fn set_review_state(doc: &mut Document, page: usize, index: usize, state: ReviewState, author: &str, meta: &Meta) -> Result { - let text = format!("{} set by {}", state.name(), if author.is_empty() { "unknown" } else { author }); - reply(doc, page, index, &text, author, meta, Some(state)) -} - -fn reply( - doc: &mut Document, - page: usize, - index: usize, - text: &str, - author: &str, - meta: &Meta, - state: Option, -) -> Result { - let (p, parent) = annot_ref(doc, page, index)?; - let pd = annot_dict(doc, parent); - if pd.name(b"Subtype") == Some(b"Popup") { - return Err(AnnotError::Invalid("pop-ups can't be replied to".into())); - } - let rect = pd.get(b"Rect").cloned().unwrap_or_else(|| num_array(&[0.0, 0.0, 0.0, 0.0])); - let mut d = base_dict("Text", [0.0; 4], p, text, author, meta); - d.set(b"Rect".to_vec(), rect); - d.set(b"IRT".to_vec(), Object::Ref(parent)); - d.set(b"F".to_vec(), Object::Int(FLAG_PRINT | FLAG_NO_ZOOM | FLAG_NO_ROTATE)); - d.set(b"Name".to_vec(), Object::name("Comment")); - if let Some(c) = pd.get(b"C") { - d.set(b"C".to_vec(), c.clone()); - } - if let Some(s) = state { - d.set(b"State".to_vec(), PdfString::text(s.name())); - d.set(b"StateModel".to_vec(), PdfString::text("Review")); - d.set(b"Subj".to_vec(), PdfString::text("Status")); - } - // An empty form: nothing is drawn for the reply itself. - let mut fd = Dict::new(); - fd.set(b"Type".to_vec(), Object::name("XObject")); - fd.set(b"Subtype".to_vec(), Object::name("Form")); - fd.set(b"BBox".to_vec(), num_array(&[0.0, 0.0, 0.0, 0.0])); - let ap = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(fd, Vec::new()))); - let mut apd = Dict::new(); - apd.set(b"N".to_vec(), Object::Ref(ap)); - d.set(b"AP".to_vec(), Object::Dict(apd)); - let r = doc.add(Object::Dict(d)); - let mut list = annots(doc, p); - list.push(Object::Ref(r)); - let i = list.len() - 1; - set_annots(doc, p, list)?; - Ok(i) -} - -fn flags(doc: &Document, r: ObjRef) -> i64 { - doc.get(r).as_dict().map(|d| annotation_flags(doc, d)).unwrap_or(0) -} - -/// Refuse to change a locked comment (Acrobat: Properties ▸ Locked). -fn unlocked(doc: &Document, r: ObjRef) -> Result<(), AnnotError> { - if flags(doc, r) & FLAG_LOCKED != 0 { Err(AnnotError::Invalid("the comment is locked".into())) } else { Ok(()) } -} - -/// Lock or unlock a comment (the Locked flag). A locked comment can't be moved, resized, -/// restyled or deleted; its text and replies stay editable, as in Acrobat. -pub fn set_locked(doc: &mut Document, page: usize, index: usize, locked: bool) -> Result<(), AnnotError> { - let (_, r) = annot_ref(doc, page, index)?; - let f = if locked { flags(doc, r) | FLAG_LOCKED } else { flags(doc, r) & !FLAG_LOCKED }; - doc.update_dict(r, |d| d.set(b"F".to_vec(), Object::Int(f)))?; - Ok(()) -} - -/// Mark or unmark a comment with a checkmark. Like Acrobat this adds a hidden state reply with -/// `/StateModel /Marked` by `author`; the latest one wins. It's private bookkeeping, not a status. -pub fn set_marked(doc: &mut Document, page: usize, index: usize, marked: bool, author: &str, meta: &Meta) -> Result { - let (p, parent) = annot_ref(doc, page, index)?; - let pd = annot_dict(doc, parent); - if pd.name(b"Subtype") == Some(b"Popup") { - return Err(AnnotError::Invalid("pop-ups can't be marked".into())); - } - let state = if marked { "Marked" } else { "Unmarked" }; - let text = format!("{state} set by {}", if author.is_empty() { "unknown" } else { author }); - let mut d = base_dict("Text", [0.0; 4], p, &text, author, meta); - d.set(b"Rect".to_vec(), pd.get(b"Rect").cloned().unwrap_or_else(|| num_array(&[0.0; 4]))); - d.set(b"IRT".to_vec(), Object::Ref(parent)); - d.set(b"F".to_vec(), Object::Int(FLAG_HIDDEN | FLAG_PRINT | FLAG_NO_ZOOM | FLAG_NO_ROTATE)); - d.set(b"State".to_vec(), PdfString::text(state)); - d.set(b"StateModel".to_vec(), PdfString::text("Marked")); - let r = doc.add(Object::Dict(d)); - let mut list = annots(doc, p); - list.push(Object::Ref(r)); - let i = list.len() - 1; - set_annots(doc, p, list)?; - Ok(i) -} - -/// Move a comment (and its pop-up) by `(dx, dy)` points. The appearance moves with `/Rect`. -pub fn move_annotation(doc: &mut Document, page: usize, index: usize, dx: f64, dy: f64, meta: &Meta) -> Result<(), AnnotError> { - if !finite(&[dx, dy]) { - return Err(AnnotError::Invalid("invalid offset".into())); - } - let (_, r) = annot_ref(doc, page, index)?; - unlocked(doc, r)?; - let shift = |o: &Object, every: bool| -> Option { - let a = o.as_array()?; - Some(Object::Array( - a.iter() - .enumerate() - .map(|(i, v)| match v.as_f64() { - Some(x) if every || i < 4 => Object::Real(x + if i % 2 == 0 { dx } else { dy }), - _ => v.clone(), - }) - .collect(), - )) - }; - let popup = annot_dict(doc, r).reference(b"Popup"); - doc.update_dict(r, |d| { - for (k, every) in [(&b"Rect"[..], false), (b"QuadPoints", true), (b"L", false), (b"CL", true), (b"Vertices", true)] { - if let Some(v) = d.get(k).and_then(|o| shift(o, every)) { - d.set(k.to_vec(), v); - } - } - if let Some(Object::Array(list)) = d.get(b"InkList").cloned() { - let moved = list.iter().map(|s| shift(s, true).unwrap_or_else(|| s.clone())).collect(); - d.set(b"InkList".to_vec(), Object::Array(moved)); - } - touch(d, meta); - })?; - if let Some(p) = popup - && doc.get(p).as_dict().is_some() - { - doc.update_dict(p, |d| { - if let Some(v) = d.get(b"Rect").and_then(|o| shift(o, false)) { - d.set(b"Rect".to_vec(), v); - } - })?; - } - Ok(()) -} - -/// Resize a rectangle, oval, text box, stamp or drawing to `rect`. Stamps keep their appearance, -/// which PDF viewers scale from its bounding box into the new rectangle; drawings scale their -/// strokes and get a new one. -pub fn set_rect(doc: &mut Document, page: usize, index: usize, rect: [f64; 4], meta: &Meta) -> Result<(), AnnotError> { - let (_, r) = annot_ref(doc, page, index)?; - unlocked(doc, r)?; - let d = annot_dict(doc, r); - let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned(); - if subtype == "Stamp" { - let rect = normalize(rect); - if !finite(&rect) || rect[2] <= rect[0] || rect[3] <= rect[1] { - return Err(AnnotError::Invalid("invalid rectangle (too small)".into())); - } - doc.update_dict(r, |d| { - d.set(b"Rect".to_vec(), num_array(&rect)); - touch(d, meta); - })?; - return Ok(()); - } - if subtype == "Ink" { - return resize_ink(doc, r, normalize(rect), meta); - } - if !matches!(subtype.as_str(), "Square" | "Circle" | "FreeText") { - return Err(AnnotError::Invalid(format!("{subtype} comments can't be resized"))); - } - apply_text_box(doc, r, rect, meta, false) -} - -/// At most this many points reshape a comment (a drawn polygon has a handful). -const MAX_POINTS: usize = 100_000; - -/// Reshape a line, connected lines, a polygon or a cloud: its points in user space (a line's two -/// ends, the others' corners), as dragging the comment's handles sets them. `/Rect` follows the -/// points with room for the line's width and endings, as when the comment was drawn, and the -/// appearance is redrawn. A measurement keeps the points it measured. -pub fn set_points(doc: &mut Document, page: usize, index: usize, points: &[[f64; 2]], meta: &Meta) -> Result<(), AnnotError> { - let (_, r) = annot_ref(doc, page, index)?; - unlocked(doc, r)?; - let d = annot_dict(doc, r); - if d.contains(b"Measure") { - return Err(AnnotError::Invalid("a measurement's points are where it measured".into())); - } - if points.len() > MAX_POINTS { - return Err(AnnotError::Invalid(format!("at most {MAX_POINTS} points"))); - } - let style = Style { width: border_width_of(&d), ..Style::default() }; - let ends = line_endings_of(&d).unwrap_or_default(); - let (start, end) = (ends.first().copied().unwrap_or(LineEnding::None), ends.get(1).copied().unwrap_or(LineEnding::None)); - let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned(); - let (shape, key): (Shape, &[u8]) = match (subtype.as_str(), points) { - ("Line", [from, to]) => (Shape::Line { from: *from, to: *to, start, end }, b"L"), - ("Line", _) => return Err(AnnotError::Invalid("a line has two ends".into())), - ("PolyLine", _) => (Shape::PolyLine { vertices: points.to_vec(), start, end }, b"Vertices"), - ("Polygon", _) => { - let cloud = d.get(b"BE").and_then(|b| b.as_dict()).is_some_and(|b| b.name(b"S") == Some(b"C")); - (Shape::Polygon { vertices: points.to_vec(), cloud }, b"Vertices") - } - (other, _) => return Err(AnnotError::Invalid(format!("{other} comments have no points to move"))), - }; - // Checks the points too: finite, and far enough apart to draw. - let rect = rect_for(&shape, &style)?; - let before = d; - doc.update_dict(r, |d| { - d.set(key.to_vec(), num_array(&points.concat())); - d.set(b"Rect".to_vec(), num_array(&rect)); - touch(d, meta); - })?; - redraw_or_restore(doc, r, before) -} - -/// Scale a drawing's strokes into `rect`. The margin between the strokes and the old `/Rect` (half -/// the line width and a little more) stays the same on each side, so the line isn't clipped. -fn resize_ink(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta) -> Result<(), AnnotError> { - let d = annot_dict(doc, r); - let strokes: Vec> = d - .get(b"InkList") - .map(|l| doc.resolve(l)) - .and_then(|l| l.as_array().cloned()) - .unwrap_or_default() - .iter() - .map(|s| { - let v: Vec = doc.resolve(s).as_array().map(|a| a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect()).unwrap_or_default(); - v.as_chunks::<2>().0.iter().map(|p| [p[0], p[1]]).collect() - }) - .collect(); - let Some(inner) = bounds(strokes.iter().flatten().copied()) else { - return Err(AnnotError::Invalid("the drawing has no strokes".into())); - }; - let old = d.get(b"Rect").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); - let old: Vec = old.iter().filter_map(|v| doc.resolve(v).as_f64()).collect(); - let old = match old[..] { - [a, b, c, e] => normalize([a, b, c, e]), - _ => inner, - }; - let margin = [inner[0] - old[0], inner[1] - old[1], old[2] - inner[2], old[3] - inner[3]].map(|m| m.max(0.0)); - let target = [rect[0] + margin[0], rect[1] + margin[1], rect[2] - margin[2], rect[3] - margin[3]]; - if !finite(&rect) || target[2] < target[0] || target[3] < target[1] || rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 { - return Err(AnnotError::Invalid("invalid rectangle (too small)".into())); - } - // An axis with no extent (a straight stroke) is centred rather than scaled. - let axis = |v: f64, a0: f64, a1: f64, b0: f64, b1: f64| if a1 > a0 { b0 + (v - a0) / (a1 - a0) * (b1 - b0) } else { (b0 + b1) / 2.0 }; - let scaled: Vec> = strokes - .iter() - .map(|s| { - s.iter().map(|p| [axis(p[0], inner[0], inner[2], target[0], target[2]), axis(p[1], inner[1], inner[3], target[1], target[3])]).collect() - }) - .collect(); - let before = annot_dict(doc, r); - doc.update_dict(r, |d| { - d.set(b"InkList".to_vec(), Object::Array(scaled.iter().map(|s| num_array(&s.concat())).collect())); - d.set(b"Rect".to_vec(), num_array(&rect)); - touch(d, meta); - })?; - redraw_or_restore(doc, r, before) -} - -/// Store `rect` as a FreeText annotation's text box (or a square's/oval's rectangle): a callout's -/// leader line re-attaches and `/Rect` grows to hold it, and the appearance is redrawn -/// (`new_text`: for changed contents, see [`set_appearance`] and `draw_appearance`). -fn apply_text_box(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta, new_text: bool) -> Result<(), AnnotError> { - let rect = normalize(rect); - if !finite(&rect) || rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 { - return Err(AnnotError::Invalid("invalid rectangle (too small)".into())); - } - let d = annot_dict(doc, r); - if !new_text { - refuse_foreign_text(doc, &d)?; - } - // A callout's rectangle is its text box: the leader line re-attaches and `/Rect` grows to hold it. - let callout = d.get(b"CL").and_then(|o| o.as_array()).map(|a| a.iter().filter_map(|x| x.as_f64()).collect::>()).filter(|l| l.len() == 6); - let (outer, cl) = match callout { - Some(mut l) => { - let attach = callout_attach(rect, [l[2], l[3]]); - (l[4], l[5]) = (attach[0], attach[1]); - let pad = border_width_of(&d) / 2.0 + appearance::arrow_size(border_width_of(&d)) + 1.0; - let lead = grow(bounds([[l[0], l[1]], [l[2], l[3]]].into_iter()).unwrap_or_default(), pad); - ([rect[0].min(lead[0]), rect[1].min(lead[1]), rect[2].max(lead[2]), rect[3].max(lead[3])], Some(l)) - } - None => (rect, None), - }; - doc.update_dict(r, |d| { - d.set(b"Rect".to_vec(), num_array(&outer)); - match &cl { - Some(l) => { - d.set(b"CL".to_vec(), num_array(l)); - d.set(b"RD".to_vec(), num_array(&[rect[0] - outer[0], rect[1] - outer[1], outer[2] - rect[2], outer[3] - rect[3]])); - } - None => { - d.remove(b"RD"); - } - } - touch(d, meta); - })?; - let before = d; - if new_text { - if let Err(e) = draw_appearance(doc, r) { - doc.set(r, Object::Dict(before)); - return Err(e); - } - Ok(()) - } else { - redraw_or_restore(doc, r, before) - } -} - -fn border_width_of(d: &Dict) -> f64 { - d.get(b"BS").and_then(|b| b.as_dict()).and_then(|b| b.get(b"W")).and_then(|w| w.as_f64()).unwrap_or(1.0).max(0.0) -} - -/// Change a comment's colour, opacity, line width and/or line endings, and redraw it. -#[allow(clippy::too_many_arguments)] -pub fn set_style( - doc: &mut Document, - page: usize, - index: usize, - color: Option, - opacity: Option, - width: Option, - endings: Option<&[LineEnding]>, - meta: &Meta, -) -> Result<(), AnnotError> { - let (_, r) = annot_ref(doc, page, index)?; - unlocked(doc, r)?; - let d = annot_dict(doc, r); - let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned(); - // Check before changing anything: a stale appearance would contradict the new style. - if appearance::build(&d).is_none() { - return Err(AnnotError::Unsupported(subtype.clone())); - } - if color.is_some_and(|c| !finite(&c)) || opacity.is_some_and(|o| !o.is_finite()) || width.is_some_and(|w| !w.is_finite()) { - return Err(AnnotError::Invalid("invalid style".into())); - } - if let Some(ends) = endings { - let callout = subtype == "FreeText" && d.contains(b"CL"); - let pair = matches!(subtype.as_str(), "Line" | "PolyLine"); - if (callout && ends.len() != 1) || (pair && ends.len() != 2) || (!callout && !pair) { - return Err(AnnotError::Invalid("this comment has no line endings to change".into())); - } - } - refuse_foreign_text(doc, &d)?; - let free_text = subtype == "FreeText"; - let before = d; - doc.update_dict(r, |d| { - if let Some(c) = color { - if free_text { - let (_, size) = appearance::parse_da(d); - let [r, g, b] = c.map(|x| x.clamp(0.0, 1.0)); - d.set(b"DA".to_vec(), PdfString::literal(format!("{} {} {} rg /Helv {} Tf", n(r), n(g), n(b), n(size)).into_bytes())); - rich_text(d); - } else { - d.set(b"C".to_vec(), rgb(c)); - } - } - if let Some(o) = opacity { - let o = o.clamp(0.0, 1.0); - if o < 1.0 { - d.set(b"CA".to_vec(), Object::Real(o)); - } else { - d.remove(b"CA"); - } - } - if let Some(w) = width { - let mut bs = d.get(b"BS").and_then(|b| b.as_dict()).cloned().unwrap_or_default(); - bs.set(b"W".to_vec(), Object::Real(w.max(0.0))); - d.set(b"BS".to_vec(), Object::Dict(bs)); - d.remove(b"Border"); - } - if let Some(ends) = endings { - if ends.len() == 1 { - if let Some(ending) = ends.first() { - d.set(b"LE".to_vec(), Object::name(ending.name())); - } - } else if let (Some(start), Some(end)) = (ends.first(), ends.get(1)) { - d.set(b"LE".to_vec(), ending_pair(*start, *end)); - } - } - ensure_ending_room(d); - touch(d, meta); - })?; - 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 has a fill: `/IC` for shapes, `/C` for FreeText (§12.5.6). -pub fn fillable(subtype: &str) -> bool { - matches!(subtype, "Square" | "Circle" | "Polygon" | "FreeText") -} - -/// Fill a shape or FreeText comment with `fill`, or remove its fill (`None`), and -/// redraw its appearance (Acrobat: Properties ▸ Fill Color). -pub fn set_fill(doc: &mut Document, page: usize, index: usize, fill: Option, meta: &Meta) -> Result<(), AnnotError> { - let (_, r) = annot_ref(doc, page, index)?; - unlocked(doc, r)?; - let d = annot_dict(doc, r); - let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned(); - if !fillable(&subtype) { - return Err(AnnotError::Invalid("only rectangles, ovals, polygons and text boxes can be filled".into())); - } - // Check before changing anything: a stale appearance would contradict the new fill. - if appearance::build(&d).is_none() { - return Err(AnnotError::Unsupported(subtype)); - } - if fill.is_some_and(|c| !finite(&c)) { - return Err(AnnotError::Invalid("invalid fill colour".into())); - } - refuse_foreign_text(doc, &d)?; - let before = d; - doc.update_dict(r, |d| { - let key: &[u8] = if subtype == "FreeText" { b"C" } else { b"IC" }; - match fill { - Some(c) => d.set(key.to_vec(), rgb(c)), - None => { - d.remove(key); - } - } - touch(d, meta); - })?; - redraw_or_restore(doc, r, before) -} - -/// Grow a line or polyline's rectangle so a non-`None` ending is not clipped. Idempotent. -fn ensure_ending_room(d: &mut Dict) { - let subtype = d.name(b"Subtype").unwrap_or_default(); - if !matches!(subtype, b"Line" | b"PolyLine") { - return; - } - if !line_endings_of(d).is_some_and(|ends| ends.iter().any(|e| ending_draws(*e))) { - return; - } - let key: &[u8] = if subtype == b"Line" { b"L" } else { b"Vertices" }; - let Some(pts) = pair_points(d, key) else { return }; - let Some(bounds) = bounds(pts.iter().copied()) else { return }; - let w = border_width_of(d); - let need = grow(bounds, w / 2.0 + appearance::arrow_size(w) + 1.0); - let vals: Vec = d.get(b"Rect").and_then(|o| o.as_array()).map(|a| a.iter().filter_map(|x| x.as_f64()).collect()).unwrap_or_default(); - let Some(rect) = four(&vals) else { return }; - let union = [rect[0].min(need[0]), rect[1].min(need[1]), rect[2].max(need[2]), rect[3].max(need[3])]; - if union != rect { - d.set(b"Rect".to_vec(), num_array(&union)); - } -} - -fn four(v: &[f64]) -> Option<[f64; 4]> { - Some([*v.first()?, *v.get(1)?, *v.get(2)?, *v.get(3)?]) -} - -fn pair_points(d: &Dict, key: &[u8]) -> Option> { - let v: Vec = d.get(key)?.as_array()?.iter().filter_map(|o| o.as_f64()).collect(); - if v.len() < 4 || !v.len().is_multiple_of(2) || v.iter().any(|x| !x.is_finite()) { - return None; - } - Some(v.as_chunks::<2>().0.to_vec()) -} - -// ── reading ───────────────────────────────────────────────────────────────────────────────── - -/// One comment as the viewer lists it (the same fields and rules as -/// `pdfcraft_render::Annotation`), read straight from the object graph. The engine uses it to -/// refresh the comment list after a comment edit without re-inspecting the whole document. -#[derive(Clone, Debug, PartialEq)] -pub struct Summary { - pub page: usize, - pub index: usize, - pub subtype: String, - pub author: Option, - pub contents: Option, - /// `/M` as written (a PDF date string). - pub modified: Option, - pub name: Option, - pub in_reply_to: Option, - /// The page and `/Annots` position `/IRT` points at, by reference (`/NM` need not be unique). - pub parent: Option<(usize, usize)>, - /// `/RT /Group`: grouped with the `/IRT` annotation (Replace Text's caret), not a reply to it. - pub grouped: bool, - pub rect: [f32; 4], - pub color: Option<[f32; 3]>, - pub state: Option, - pub quads: Vec<[f32; 8]>, - pub locked: bool, - pub intent: Option, - /// A Fill & Sign mark ([`is_fill_sign`]). - pub fill_sign: bool, - /// The NoRotate flag (`/F` bit 5), as `pdfcraft_render::Annotation::no_rotate`. - pub no_rotate: bool, - /// A line's two ends (`/L`), or the corners of connected lines, a polygon or a cloud - /// (`/Vertices`), in user space; empty for other comments. - pub points: Vec<[f32; 2]>, -} - -fn text_value(doc: &Document, d: &Dict, key: &[u8]) -> Option { - let o = doc.resolve(d.get(key)?); - let s = match &*o { - Object::String(s) => s.to_text(), - Object::Name(n) => String::from_utf8_lossy(n).into_owned(), - _ => return None, - }; - let s = s.trim_matches('\0').trim().to_string(); - (!s.is_empty()).then_some(s) -} - -/// Whether an annotation subtype is a comment. Links, form widgets, pop-ups and the non-markup -/// multimedia and print-production annotations (ISO 32000-2 §12.5.6: Screen, Movie, RichMedia, -/// 3D, PrinterMark, TrapNet, Watermark) are not. -pub fn is_comment_subtype(subtype: &str) -> bool { - !matches!(subtype, "Link" | "Widget" | "Popup" | "Screen" | "Movie" | "RichMedia" | "3D" | "PrinterMark" | "TrapNet" | "Watermark") -} - -/// Where a reference to a reference (`5 0 obj 6 0 R endobj`) ends, as `pdfcraft_render::inspect` -/// follows it, within a few steps (a cycle stops there). -fn final_ref(doc: &Document, mut r: ObjRef) -> ObjRef { - for _ in 0..8 { - match &*doc.get(r) { - Object::Ref(next) => r = *next, - _ => break, - } - } - r -} - -/// Every comment (see [`is_comment_subtype`]), ordered by page and then top edge. -pub fn summaries(doc: &Document) -> Vec { - let mut out = Vec::new(); - let Ok(pages) = page_refs(doc) else { return out }; - let lists: Vec> = pages.iter().map(|p| annots(doc, *p)).collect(); - // Where each annotation is first listed, so a reply's `/IRT` finds its parent by reference. - let mut slots: std::collections::HashMap = std::collections::HashMap::new(); - for (page, list) in lists.iter().enumerate() { - for (index, entry) in list.iter().enumerate() { - if let Some(r) = entry.as_ref() { - slots.entry(final_ref(doc, r)).or_insert((page, index)); - } - } - } - for (page, list) in lists.iter().enumerate() { - for (index, entry) in list.iter().enumerate() { - let obj = entry.as_ref().map_or_else(|| doc.resolve(entry), |r| doc.get(final_ref(doc, r))); - let Some(d) = obj.as_dict() else { continue }; - let Some(subtype) = d.name(b"Subtype").map(|s| String::from_utf8_lossy(s).into_owned()) else { continue }; - if !is_comment_subtype(&subtype) { - continue; - } - let nums = |k: &[u8]| -> Vec { - d.get(k) - .map(|o| doc.resolve(o)) - .and_then(|o| o.as_array().map(|a| a.iter().map(|x| doc.resolve(x).as_f64().unwrap_or(0.0) as f32).collect())) - .unwrap_or_default() - }; - let r = nums(b"Rect"); - let rect = if r.len() == 4 { [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])] } else { [0.0; 4] }; - let c = nums(b"C"); - let mut color = (c.len() == 3).then(|| [c[0], c[1], c[2]]); - if subtype == "FreeText" - && let Some(da) = text_value(doc, d, b"DA") - { - let t: Vec<&str> = da.split_whitespace().collect(); - if let Some(i) = t.iter().position(|x| *x == "rg") - && i >= 3 - { - let f = |k: usize| t[k].parse::().unwrap_or(0.0); - color = Some([f(i - 3), f(i - 2), f(i - 1)]); - } - } - let quads = nums(b"QuadPoints").as_chunks::<8>().0.to_vec(); - // Keep in step with `pdfcraft_render::inspect`. - let points = match subtype.as_str() { - "Line" => Some(nums(b"L")).filter(|l| l.len() == 4).map(|l| l.as_chunks::<2>().0.to_vec()).unwrap_or_default(), - "PolyLine" | "Polygon" => nums(b"Vertices").as_chunks::<2>().0.to_vec(), - _ => Vec::new(), - }; - let irt = d.get(b"IRT").map(|o| o.as_ref().map_or_else(|| doc.resolve(o), |r| doc.get(final_ref(doc, r)))); - let in_reply_to = irt.and_then(|o| o.as_dict().cloned()).and_then(|p| text_value(doc, &p, b"NM")); - let parent = d.get(b"IRT").and_then(Object::as_ref).and_then(|r| slots.get(&final_ref(doc, r)).copied()); - out.push(Summary { - page, - index, - subtype, - author: text_value(doc, d, b"T"), - // Acrobat ends lines with CR, which the interface would draw as nothing (keep in - // step with `pdfcraft_render::inspect`). - contents: text_value(doc, d, b"Contents").map(|s| s.replace("\r\n", "\n").replace('\r', "\n")), - modified: text_value(doc, d, b"M"), - name: text_value(doc, d, b"NM"), - in_reply_to, - parent, - grouped: d.name(b"RT") == Some(b"Group"), - rect, - color, - state: text_value(doc, d, b"State"), - quads, - locked: annotation_flags(doc, d) & FLAG_LOCKED != 0, - intent: d.get(b"IT").and_then(|o| doc.resolve(o).as_name().map(|n| String::from_utf8_lossy(n).into_owned())), - fill_sign: is_fill_sign(doc, d), - no_rotate: drawn_upright(doc, d), - points, - }); - } - } - out.sort_by(|a, b| a.page.cmp(&b.page).then(b.rect[3].total_cmp(&a.rect[3]))); - out -} - -/// Change a comment's author (`/T`), subject (`/Subj`) and, for notes, icon (`/Name`, redrawn). -/// `None` leaves a value as it is. -pub fn set_info( - doc: &mut Document, - page: usize, - index: usize, - author: Option<&str>, - subject: Option<&str>, - icon: Option, - meta: &Meta, -) -> Result<(), AnnotError> { - let (_, r) = annot_ref(doc, page, index)?; - unlocked(doc, r)?; - let is_note = annot_dict(doc, r).name(b"Subtype") == Some(b"Text"); - if icon.is_some() && !is_note { - return Err(AnnotError::Invalid("only sticky notes have an icon".into())); - } - doc.update_dict(r, |d| { - if let Some(a) = author { - d.set(b"T".to_vec(), PdfString::text(a)); - } - if let Some(s) = subject { - d.set(b"Subj".to_vec(), PdfString::text(s)); - } - if let Some(i) = icon { - d.set(b"Name".to_vec(), Object::name(i.name())); - } - touch(d, meta); - })?; - if icon.is_some() { - set_appearance(doc, r)?; - } - Ok(()) -} - -/// What Comment properties shows for one comment. -#[derive(Clone, Debug, PartialEq)] -pub struct Props { - pub subtype: String, - pub author: String, - pub subject: String, - pub color: Option, - pub opacity: f64, - /// Border / line width, for shapes, lines, drawings and text boxes. - pub width: Option, - /// Notes only. - pub icon: Option, - /// `/M`, as written. - pub modified: Option, - /// The appearance can be redrawn (so colour, opacity and width can change). - pub restylable: bool, - /// The Locked flag. - pub locked: bool, - /// `/LE`: two names for a line or polyline (`None` when unset), one for a callout. - pub endings: Option>, - /// Whether the comment has a fill control (see [`fillable`]). - pub fillable: bool, - /// 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)`. -pub fn props(doc: &Document, page: usize, index: usize) -> Option { - let p = page_ref(doc, page).ok()?; - let entry = annots(doc, p).get(index).cloned()?; - let obj = doc.resolve(&entry); - let d = obj.as_dict()?; - let subtype = String::from_utf8_lossy(d.name(b"Subtype")?).into_owned(); - let c: Vec = d.get(b"C").and_then(|o| o.as_array().map(|a| a.iter().filter_map(|x| x.as_f64()).collect())).unwrap_or_default(); - let color = if subtype == "FreeText" { Some(appearance::parse_da(d).0) } else { (c.len() == 3).then(|| [c[0], c[1], c[2]]) }; - let width = matches!(subtype.as_str(), "Square" | "Circle" | "Line" | "Ink" | "Polygon" | "PolyLine" | "FreeText").then(|| { - if subtype == "FreeText" { - if !d.contains(b"BS") && !d.contains(b"Border") { 0.0 } else { appearance::border_width(d) } - } else { - d.get(b"BS").and_then(|b| b.as_dict()).and_then(|b| b.get(b"W")).and_then(|w| w.as_f64()).unwrap_or(1.0) - } - }); - Some(Props { - author: text_value(doc, d, b"T").unwrap_or_default(), - subject: text_value(doc, d, b"Subj").unwrap_or_default(), - color, - opacity: d.get(b"CA").and_then(|o| o.as_f64()).unwrap_or(1.0), - width, - icon: (subtype == "Text").then(|| d.name(b"Name").and_then(|n| NoteIcon::from_name(&String::from_utf8_lossy(n))).unwrap_or(NoteIcon::Note)), - modified: text_value(doc, d, b"M"), - restylable: appearance::build(d).is_some(), - 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), - }) -} - -/// A shape’s `/IC` or FreeText `/C` as RGB. Grey and CMYK fills are converted. -fn fill_of(d: &Dict) -> Option { - let key: &[u8] = if d.name(b"Subtype") == Some(b"FreeText") { b"C" } else { b"IC" }; - let c: Vec = d.get(key)?.as_array()?.iter().filter_map(|x| x.as_f64()).collect(); - let rgb = match c.as_slice() { - [g] => [*g; 3], - [r, g, b] => [*r, *g, *b], - [c, m, y, k] => [(1.0 - c) * (1.0 - k), (1.0 - m) * (1.0 - k), (1.0 - y) * (1.0 - k)], - _ => return None, - }; - finite(&rgb).then(|| rgb.map(|x| x.clamp(0.0, 1.0))) -} - -/// `/LE` for a line, polyline or callout. Unknown names yield `None` so the control stays hidden. -fn line_endings_of(d: &Dict) -> Option> { - let parse = |name: &[u8]| std::str::from_utf8(name).ok().and_then(LineEnding::parse); - match d.name(b"Subtype")? { - b"Line" | b"PolyLine" => match d.get(b"LE") { - None => Some(vec![LineEnding::None, LineEnding::None]), - Some(o) => o.as_array().filter(|a| a.len() == 2)?.iter().map(|e| parse(e.as_name()?)).collect(), - }, - b"FreeText" if d.contains(b"CL") => Some(vec![parse(d.name(b"LE").unwrap_or(b"None"))?]), - _ => None, - } -} - -/// Replace Text (Acrobat's proposal): strike out `quads` and add a caret at the end of the -/// struck text holding `replacement`, grouped with the strikeout (`/IRT` + `/RT /Group`) so they -/// move, list and delete as one. Returns the strikeout's index. -/// -/// The two annotations need their own `/NM`: XFDF names a reply by the `/NM` of what it replies -/// to, so one id for both writes `inreplyto` equal to the caret's own `name`, and a reader that -/// resolves names (Acrobat, and our own importer) loses the pair. `caret_meta` is a second id -/// from the caller, so the edit stays deterministic. -#[allow(clippy::too_many_arguments)] -pub fn add_text_replacement( - doc: &mut Document, - page: usize, - quads: &[[f64; 8]], - replacement: &str, - author: &str, - strike: &Style, - caret: &Style, - meta: &Meta, - caret_meta: &Meta, -) -> Result { - let last = *quads.last().ok_or_else(|| AnnotError::Invalid("select the text to replace first".into()))?; - let s = add_annotation( - doc, - &NewAnnotation { - page, - shape: Shape::TextMarkup { kind: Markup::StrikeOut, quads: quads.to_vec() }, - style: strike.clone(), - contents: String::new(), - author: author.into(), - }, - meta, - )?; - let (_, sr) = annot_ref(doc, page, s)?; - doc.update_dict(sr, |d| d.set(b"IT".to_vec(), Object::name("StrikeOutTextEdit")))?; - // The caret sits on the baseline at the end of the last line, its point at the text. - let xs = [last[0], last[2], last[4], last[6]]; - let ys = [last[1], last[3], last[5], last[7]]; - let x = xs.iter().copied().fold(f64::MIN, f64::max); - let (y0, y1) = (ys.iter().copied().fold(f64::MAX, f64::min), ys.iter().copied().fold(f64::MIN, f64::max)); - let h = ((y1 - y0) * 0.6).clamp(4.0, 24.0); - let rect = [x - h / 2.0, y0 - h * 0.6, x + h / 2.0, y0 + h * 0.4]; - let c = add_annotation( - doc, - &NewAnnotation { page, shape: Shape::Caret { rect }, style: caret.clone(), contents: replacement.into(), author: author.into() }, - caret_meta, - )?; - let (_, cr) = annot_ref(doc, page, c)?; - doc.update_dict(cr, |d| { - d.set(b"IRT".to_vec(), Object::Ref(sr)); - d.set(b"RT".to_vec(), Object::name("Group")); - d.set(b"Subj".to_vec(), PdfString::text("Replace Text")); - })?; - Ok(s) -} - -/// The Eraser (Draw tools): remove the parts of drawing `(page, index)` within `radius` points -/// of `path`, splitting strokes where they are cut. A drawing with nothing left is deleted. -/// Returns whether anything was erased. -pub fn erase_ink(doc: &mut Document, page: usize, index: usize, path: &[[f64; 2]], radius: f64, meta: &Meta) -> Result { - let (_, r) = annot_ref(doc, page, index)?; - unlocked(doc, r)?; - let d = annot_dict(doc, r); - if d.name(b"Subtype") != Some(b"Ink") { - return Err(AnnotError::Invalid("only drawings can be erased".into())); - } - if path.is_empty() || !path.iter().all(|p| finite(p)) || !radius.is_finite() || radius <= 0.0 { - return Err(AnnotError::Invalid("invalid eraser path".into())); - } - let strokes: Vec> = d - .get(b"InkList") - .map(|l| doc.resolve(l)) - .and_then(|l| l.as_array().cloned()) - .unwrap_or_default() - .iter() - .map(|s| { - let v: Vec = doc.resolve(s).as_array().map(|a| a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect()).unwrap_or_default(); - v.as_chunks::<2>().0.iter().map(|p| [p[0], p[1]]).collect() - }) - .collect(); - // Distance from a point to the eraser's path (segments, or a single point). - let near = |p: [f64; 2]| -> bool { - if path.len() == 1 { - return (p[0] - path[0][0]).hypot(p[1] - path[0][1]) <= radius; - } - path.windows(2).any(|w| { - let (a, b) = (w[0], w[1]); - let (dx, dy) = (b[0] - a[0], b[1] - a[1]); - let len2 = dx * dx + dy * dy; - let t = if len2 > 0.0 { (((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len2).clamp(0.0, 1.0) } else { 0.0 }; - (p[0] - (a[0] + t * dx)).hypot(p[1] - (a[1] + t * dy)) <= radius - }) - }; - let mut kept: Vec> = Vec::new(); - let mut erased = false; - for s in &strokes { - // Densify so long segments are cut where the eraser crosses them. - let mut pts: Vec<[f64; 2]> = Vec::new(); - for w in s.windows(2) { - let n = ((w[1][0] - w[0][0]).hypot(w[1][1] - w[0][1]) / (radius / 2.0)).ceil().clamp(1.0, 2000.0) as usize; - for k in 0..n { - let t = k as f64 / n as f64; - pts.push([w[0][0] + (w[1][0] - w[0][0]) * t, w[0][1] + (w[1][1] - w[0][1]) * t]); - } - } - if let Some(last) = s.last() { - pts.push(*last); - } - let mut run: Vec<[f64; 2]> = Vec::new(); - for p in pts { - if near(p) { - erased = true; - if run.len() >= 2 { - kept.push(std::mem::take(&mut run)); - } - run.clear(); - } else { - run.push(p); - } - } - if run.len() >= 2 { - kept.push(run); - } - } - if !erased { - return Ok(false); - } - if kept.is_empty() { - delete_annotation(doc, page, index)?; - return Ok(true); - } - let width = d.get(b"BS").and_then(|b| b.as_dict()).and_then(|b| b.get(b"W")).and_then(|w| w.as_f64()).unwrap_or(1.0); - let rect = grow(bounds(kept.iter().flatten().copied()).unwrap_or_default(), width / 2.0 + 1.0); - doc.update_dict(r, |d| { - d.set(b"InkList".to_vec(), Object::Array(kept.iter().map(|s| num_array(&s.concat())).collect())); - d.set(b"Rect".to_vec(), num_array(&rect)); - touch(d, meta); - })?; - set_appearance(doc, r)?; - Ok(true) -} diff --git a/crates/annot/src/lookup.rs b/crates/annot/src/lookup.rs new file mode 100644 index 0000000000..9af83b0542 --- /dev/null +++ b/crates/annot/src/lookup.rs @@ -0,0 +1,260 @@ +use super::info::text_value; +use super::types::{FLAG_HIDDEN, FLAG_NO_ROTATE, FLAG_NO_VIEW, Shape}; +use super::{AnnotError, Dict, Document, ObjRef, Object, appearance}; + +// ── pages and /Annots ─────────────────────────────────────────────────────────────────────── + +/// The page object of each leaf page, in order. +pub fn page_refs(doc: &Document) -> Result, AnnotError> { + let root = doc.root().ok_or(AnnotError::NoPageTree)?; + let pages = doc.get(root).as_dict().and_then(|d| d.reference(b"Pages")).ok_or(AnnotError::NoPageTree)?; + Ok(PageWalk::new(doc, pages).collect()) +} + +/// Depth-first walk over the leaf pages under `top`. It keeps one cursor per open `/Kids` array, +/// never one entry per child, so a child listed many times or shared between nodes is skipped by +/// the seen check without being queued first (`pdfcraft_model::pages` walks the same way). +/// Cycles end at the seen check, a `/Kids` array object shared by several nodes is read once, +/// and at most a million nodes and four million `/Kids` entries are read. +pub(crate) struct PageWalk<'a> { + doc: &'a Document, + seen: std::collections::HashSet, + pub(crate) pending: Vec<(std::sync::Arc, usize)>, + /// `/Kids` entries read so far. + pub(crate) examined: usize, +} + +impl<'a> PageWalk<'a> { + pub(crate) fn new(doc: &'a Document, top: ObjRef) -> Self { + let top = std::sync::Arc::new(Object::Array(vec![Object::Ref(top)])); + Self { doc, seen: Default::default(), pending: vec![(top, 0)], examined: 0 } + } +} + +impl Iterator for PageWalk<'_> { + type Item = ObjRef; + + fn next(&mut self) -> Option { + while let Some((kids, next)) = self.pending.last_mut() { + let Some(kid) = kids.as_array().and_then(|a| a.get(*next)) else { + self.pending.pop(); + continue; + }; + let node = kid.as_ref(); + *next += 1; + self.examined += 1; + let Some(node) = node else { continue }; + if self.seen.len() >= 1_000_000 || self.examined > 4_000_000 { + self.pending.clear(); + return None; + } + if !self.seen.insert(node) { + continue; + } + let obj = self.doc.get(node); + let Some(d) = obj.as_dict() else { continue }; + match d.get(b"Kids") { + // A `/Kids` array object shared by several nodes is read under the first: under + // the others it would only list nodes already seen. + Some(Object::Ref(array)) if d.name(b"Type") != Some(b"Page") => { + if self.seen.insert(*array) { + self.pending.push((self.doc.get(*array), 0)); + } + } + Some(kids) if d.name(b"Type") != Some(b"Page") => self.pending.push((self.doc.resolve(kids), 0)), + _ => return Some(node), + } + } + None + } +} + +pub(crate) fn page_ref(doc: &Document, page: usize) -> Result { + page_refs(doc)?.get(page).copied().ok_or(AnnotError::NoSuchPage(page)) +} + +/// The entries of a page's `/Annots` (references or inline dictionaries). +pub(crate) fn annots(doc: &Document, page: ObjRef) -> Vec { + let obj = doc.get(page); + let Some(a) = obj.as_dict().and_then(|d| d.get(b"Annots")) else { return Vec::new() }; + let list = doc.resolve(a).as_array().cloned().unwrap_or_default(); + #[cfg(test)] + ANNOTS_ENTRIES_COPIED.with(|n| n.set(n.get() + list.len())); + list +} + +#[cfg(test)] +thread_local! { + /// Entries copied out of pages' `/Annots` arrays. + pub(crate) static ANNOTS_ENTRIES_COPIED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Replace a page's `/Annots`, writing into the shared array object when it is indirect. An empty +/// list removes a direct `/Annots`. +pub fn set_annots(doc: &mut Document, page: ObjRef, list: Vec) -> Result<(), pdfcraft_cos::CosError> { + let existing = doc.get(page).as_dict().and_then(|d| d.reference(b"Annots")); + match existing { + Some(r) if doc.get(r).as_array().is_some() => doc.set(r, Object::Array(list)), + _ => doc.update_dict(page, |d| { + if list.is_empty() { + d.remove(b"Annots"); + } else { + d.set(b"Annots".to_vec(), Object::Array(list)); + } + })?, + } + Ok(()) +} + +/// The annotation at `(page, index)` as an indirect object (inline dictionaries are promoted). +pub(crate) fn annot_ref(doc: &mut Document, page: usize, index: usize) -> Result<(ObjRef, ObjRef), AnnotError> { + let p = page_ref(doc, page)?; + let mut list = annots(doc, p); + let entry = list.get(index).cloned().ok_or(AnnotError::NoSuchAnnotation { page, index })?; + let r = match entry { + Object::Ref(r) if doc.get(r).as_dict().is_some() => r, + Object::Dict(d) => { + let r = doc.add(Object::Dict(d)); + list[index] = Object::Ref(r); + set_annots(doc, p, list)?; + r + } + _ => return Err(AnnotError::NoSuchAnnotation { page, index }), + }; + Ok((p, r)) +} + +pub(crate) fn annot_dict(doc: &Document, r: ObjRef) -> Dict { + doc.get(r).as_dict().cloned().unwrap_or_default() +} + +/// A Fill & Sign item: typed text, a check/cross/dot/line, or a typed, drawn or image signature. +/// +/// New items carry `/PCFillSign`. Older files are recognised from the dictionaries PdfCraft +/// already writes (`/IT /FreeTextTypeWriter`, stamp `/Name`, drawn ink whose subject is +/// "Signature"). +pub fn is_fill_sign(doc: &Document, d: &Dict) -> bool { + if matches!(d.get(b"PCFillSign").map(|o| doc.resolve(o)).as_deref(), Some(Object::Bool(true))) { + return true; + } + let name_is = |key: &[u8], expected: &[u8]| d.get(key).is_some_and(|o| doc.resolve(o).as_name() == Some(expected)); + if name_is(b"Subtype", b"FreeText") && name_is(b"IT", b"FreeTextTypeWriter") { + return true; + } + if name_is(b"Subtype", b"Stamp") { + if matches!(d.name(b"Name"), Some(b"PCCheck" | b"PCCross" | b"PCDot" | b"PCLine" | b"PCTypedSignature")) { + return true; + } + if matches!(d.name(b"Name"), Some(b"PCCustomSignature" | b"PCCustomInitials")) + && matches!(d.get(b"PCPictureImage").map(|o| doc.resolve(o)).as_deref(), Some(Object::Bool(true))) + { + return true; + } + } + name_is(b"Subtype", b"Ink") && text_value(doc, d, b"Subj").as_deref() == Some("Signature") +} + +pub(crate) fn annotation_flags(doc: &Document, d: &Dict) -> i64 { + d.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0) +} + +/// Whether the renderer keeps the annotation upright on a turned page: the NoRotate flag, with +/// `/F` read as the renderer reads it (a real truncated, a value that isn't a `u32` no flags). +pub(crate) fn drawn_upright(doc: &Document, d: &Dict) -> bool { + let f = match d.get(b"F").map(|f| doc.resolve(f)).as_deref() { + Some(Object::Int(i)) => *i, + Some(Object::Real(r)) => *r as i64, + _ => 0, + }; + u32::try_from(f).is_ok_and(|f| i64::from(f) & FLAG_NO_ROTATE != 0) +} + +/// Whether any page has a Fill & Sign annotation that flatten would bake in (not hidden). +pub fn has_visible_fill_sign(doc: &Document) -> bool { + let Ok(pages) = page_refs(doc) else { return false }; + for page in pages { + for entry in annots(doc, page) { + let obj = doc.resolve(&entry); + let Some(d) = obj.as_dict() else { continue }; + let flags = annotation_flags(doc, d); + if flags & (FLAG_HIDDEN | FLAG_NO_VIEW) != 0 { + continue; + } + if is_fill_sign(doc, d) { + return true; + } + } + } + false +} + +pub(crate) fn fill_sign_shape(shape: &Shape) -> bool { + match shape { + Shape::Typewriter { .. } | Shape::Mark { .. } | Shape::Signature { .. } | Shape::TypedSignature { .. } => true, + Shape::CustomStamp { name, image: true, .. } => name == "Signature" || name == "Initials", + _ => false, + } +} + +/// The embedded image of a Fill & Sign image signature or initials (0-based target). +/// Other stamps have appearances that can't be represented by this image alone. +pub fn signature_image(doc: &Document, page: usize, index: usize) -> Result, AnnotError> { + let p = page_ref(doc, page)?; + let list = annots(doc, p); + let entry = list.get(index).ok_or(AnnotError::NoSuchAnnotation { page, index })?; + let obj = doc.resolve(entry); + let Some(d) = obj.as_dict() else { return Ok(None) }; + Ok((d.name(b"Subtype") == Some(b"Stamp") + && matches!(d.name(b"Name"), Some(b"PCCustomSignature" | b"PCCustomInitials")) + && matches!(d.get(b"PCPictureImage"), Some(Object::Bool(true)))) + .then(|| d.reference(b"PCPicture")) + .flatten()) +} + +/// The page `/Rotate` the picture of the image signature at `(page, index)` is drawn turned back +/// by, so it reads upright on a page shown that way: 0 for one added to an unturned page, or by +/// another app. The picture appears turned by the page's current rotation less this. +pub fn picture_rotation(doc: &Document, page: usize, index: usize) -> Result { + let p = page_ref(doc, page)?; + let list = annots(doc, p); + let entry = list.get(index).ok_or(AnnotError::NoSuchAnnotation { page, index })?; + Ok(doc.resolve(entry).as_dict().map_or(0, appearance::picture_turn)) +} + +/// Leave page `page` of (a copy of) a document showing only its annotation at `index`, without +/// the page's content or other annotations, so a render draws that annotation alone: to lay it +/// over the page while it is dragged. `false`, with `doc` unchanged, when the annotation has no +/// normal appearance (`/AP /N`) for that render to draw. +pub fn isolate_annotation(doc: &mut Document, page: usize, index: usize) -> Result { + isolate_annotations(doc, page, &[index]) +} + +/// [`isolate_annotation`] for several annotations of one page: those of them with a normal +/// appearance stay, in their order on the page. `false`, with `doc` unchanged, when none has one. +/// An index that isn't one of the page's annotations is an error even when the others are, and +/// leaves `doc` unchanged too. +pub fn isolate_annotations(doc: &mut Document, page: usize, indices: &[usize]) -> Result { + let p = page_ref(doc, page)?; + let list = annots(doc, p); + let mut kept = Vec::new(); + for (index, entry) in list.iter().enumerate() { + if !indices.contains(&index) { + continue; + } + let obj = doc.resolve(entry); + if obj.as_dict().and_then(|d| d.get(b"AP")).is_some_and(|ap| doc.resolve(ap).as_dict().is_some_and(|ap| ap.contains(b"N"))) { + kept.push(entry.clone()); + } + } + if let Some(&index) = indices.iter().find(|&&i| i >= list.len()) { + return Err(AnnotError::NoSuchAnnotation { page, index }); + } + if kept.is_empty() { + return Ok(false); + } + set_annots(doc, p, kept)?; + doc.update_dict(p, |d| { + d.remove(b"Contents"); + })?; + Ok(true) +} diff --git a/crates/annot/src/modify.rs b/crates/annot/src/modify.rs new file mode 100644 index 0000000000..a615b8e013 --- /dev/null +++ b/crates/annot/src/modify.rs @@ -0,0 +1,873 @@ +use super::create::{ + add_annotation, base_dict, bounds, callout_attach, ending_draws, ending_pair, finite, grow, ink_bounds, normalize, num_array, rect_for, rgb, +}; +use super::draw::{draw_appearance, redraw_or_restore, set_appearance}; +use super::info::{line_endings_of, text_value}; +use super::lookup::{annot_dict, annot_ref, annotation_flags, annots, page_ref, set_annots}; +use super::types::{ + FLAG_HIDDEN, FLAG_LOCKED, FLAG_NO_ROTATE, FLAG_NO_ZOOM, FLAG_PRINT, LineEnding, Markup, Meta, NewAnnotation, ReviewState, Rgb, Shape, Style, + StyleChange, TextReplacement, +}; +use super::{AnnotError, Dict, Document, HashMap, HashSet, ObjRef, Object, PdfString, appearance}; + +/// A comment another program drew, whose text the standard fonts can't draw. Redrawing it would +/// replace those characters with `?`, so the edit is refused before the dictionary changes. +pub(crate) fn refuse_foreign_text(doc: &Document, d: &Dict) -> Result<(), AnnotError> { + let drawn_elsewhere = + d.get(b"AP").is_some_and(|ap| doc.resolve(ap).as_dict().is_some_and(|ap| ap.contains(b"N"))) && pdfcraft_fonts::undrawn_mark(d).is_none(); + if drawn_elsewhere && let Some((what, c)) = appearance::undrawable_text(d) { + return Err(AnnotError::Undrawable(format!( + "PdfCraft can't redraw this comment: the standard fonts can't draw \"{}\" (U+{:04X}) in {what}, which would then show as ? (the comment was left as it is)", + c.escape_debug(), + u32::from(c) + ))); + } + Ok(()) +} + +/// Format a number for content streams and DA strings. +pub(crate) fn n(v: f64) -> String { + let s = format!("{:.3}", if v.abs() < 5e-4 { 0.0 } else { v }); + let s = s.trim_end_matches('0').trim_end_matches('.'); + if s == "-0" { "0".into() } else { s.into() } +} + +// ── edits ─────────────────────────────────────────────────────────────────────────────────── + +/// A text box's rich text (§12.7.4.3): `/DS` (default style) and `/RC` (XHTML) written from its +/// plain `/Contents`, `/DA` and `/Q`, so viewers that edit rich text show the same thing. +pub(crate) fn rich_text(d: &mut Dict) { + let text = d.get(b"Contents").and_then(|c| c.as_string()).map(PdfString::to_text).unwrap_or_default(); + let (col, size) = appearance::parse_da(d); + let hex = format!( + "#{:02X}{:02X}{:02X}", + (col[0].clamp(0.0, 1.0) * 255.0).round() as u8, + (col[1].clamp(0.0, 1.0) * 255.0).round() as u8, + (col[2].clamp(0.0, 1.0) * 255.0).round() as u8 + ); + let align = match d.get(b"Q").and_then(Object::as_int) { + Some(1) => "center", + Some(2) => "right", + _ => "left", + }; + let esc = |s: &str| s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """); + let body: String = text.split('\n').map(|line| format!("

{}

", esc(line.trim_end_matches('\r')))).collect(); + let style = format!( + "font-size:{}pt;text-align:{align};color:{hex};font-weight:normal;font-style:normal;font-family:Helvetica;font-stretch:normal", + n(size) + ); + let rc = format!( + "{body}" + ); + d.set(b"DS".to_vec(), PdfString::text(&format!("font: Helvetica {}pt; text-align:{align}; color:{hex}", n(size)))); + d.set(b"RC".to_vec(), PdfString::text(&rc)); +} + +pub(crate) fn touch(d: &mut Dict, meta: &Meta) { + if let Some(date) = &meta.date { + d.set(b"M".to_vec(), PdfString::literal(date.as_bytes().to_vec())); + } +} + +/// Delete a comment together with its pop-up and its replies (and theirs), as Acrobat does. +/// Refused while it, or a reply that would go with it, is locked. +pub fn delete_annotation(doc: &mut Document, page: usize, index: usize) -> Result<(), AnnotError> { + deletable(doc, page, index)?; + lift_annotation(doc, page, index) +} + +/// Whether [`delete_annotation`] deletes the annotation at `(page, index)`, or why not, without +/// changing anything: what a Delete command asks before it is offered. It is refused while the +/// annotation is locked, or a reply that would go with it is. +pub fn deletable(doc: &Document, page: usize, index: usize) -> Result<(), AnnotError> { + let list = annots(doc, page_ref(doc, page)?); + let target = list.get(index).ok_or(AnnotError::NoSuchAnnotation { page, index })?; + if let Some(r) = target.as_ref() { + unlocked(doc, r)?; + } + let doomed = with_replies_and_popups(doc, &list, seed(target)); + // A locked reply can't be deleted on its own, so it doesn't go with the comment or reply it + // answers either. Pop-ups follow their comment. + for (_, e) in list.iter().enumerate().filter(|(i, e)| *i != index && goes_with(e, &doomed)) { + let obj = doc.resolve(e); + if let Some(d) = obj.as_dict().filter(|d| d.name(b"Subtype") != Some(b"Popup".as_slice())) + && annotation_flags(doc, d) & FLAG_LOCKED != 0 + { + return Err(AnnotError::Invalid("a reply to it is locked".into())); + } + } + Ok(()) +} + +/// Take the annotation at `(page, index)` out of its page with its pop-up and replies, as +/// [`delete_annotation`] does but whatever their locks: for drawing the page beneath it on a +/// copy of the document (a stamp or signature being dragged), which changes nothing. +pub fn lift_annotation(doc: &mut Document, page: usize, index: usize) -> Result<(), AnnotError> { + let p = page_ref(doc, page)?; + let list = annots(doc, p); + let target = list.get(index).ok_or(AnnotError::NoSuchAnnotation { page, index })?; + let doomed = with_replies_and_popups(doc, &list, seed(target)); + let kept: Vec = list.iter().enumerate().filter(|(i, e)| *i != index && !goes_with(e, &doomed)).map(|(_, e)| e.clone()).collect(); + Ok(set_annots(doc, p, kept)?) +} + +/// What deleting the `/Annots` entry `target` starts from: its reference, or for an inline +/// comment (which has none to collect) the pop-up it names, which still goes. +fn seed(target: &Object) -> Option { + target.as_ref().or_else(|| target.as_dict().and_then(|d| d.reference(b"Popup"))) +} + +/// Whether the `/Annots` entry `entry` goes with `going` (from [`with_replies_and_popups`]): it is +/// one of them, or it is written inline (a file can, though the standard asks for references) and +/// replies to (`/IRT`) or pops up for (`/Parent`) one of them. An inline entry has no reference +/// for the walk to collect, so it is matched here. +pub fn goes_with(entry: &Object, going: &HashSet) -> bool { + match entry.as_ref() { + Some(r) => going.contains(&r), + None => entry.as_dict().is_some_and(|d| [d.reference(b"IRT"), d.reference(b"Parent")].into_iter().flatten().any(|r| going.contains(&r))), + } +} + +/// `doomed` and what goes with it, as Acrobat deletes a comment: the entries of `list` (one or +/// more pages' `/Annots`) that reply to (`/IRT`) or pop up for (`/Parent`) anything going, their +/// own replies, and the pop-ups going annotations name (`/Popup`). An inline reply has no +/// reference to collect, but the pop-up it names is. Each annotation is visited once, so a long or +/// cyclic chain costs no more than its length. Filter a list with [`goes_with`], which also +/// matches inline entries. +pub fn with_replies_and_popups(doc: &Document, list: &[Object], doomed: impl IntoIterator) -> HashSet { + let mut hanging: HashMap> = HashMap::new(); + for e in list { + let obj = doc.resolve(e); + let Some(d) = obj.as_dict() else { continue }; + // What goes with the annotation this entry hangs off: the entry, or for an inline one the + // pop-up it names. + let Some(goes) = e.as_ref().or_else(|| d.reference(b"Popup")) else { continue }; + for on in [d.reference(b"IRT"), d.reference(b"Parent")].into_iter().flatten() { + hanging.entry(on).or_default().push(goes); + } + } + let mut going = HashSet::new(); + let mut todo: Vec = doomed.into_iter().collect(); + while let Some(r) = todo.pop() { + if !going.insert(r) { + continue; + } + todo.extend(hanging.get(&r).into_iter().flatten()); + todo.extend(doc.get(r).as_dict().and_then(|d| d.reference(b"Popup"))); + } + going +} + +/// Change a comment's text. Text boxes are redrawn to show it, and their rectangle follows the +/// new text: the wrap width and top edge stay, the height fits the wrapped lines. +pub fn set_contents(doc: &mut Document, page: usize, index: usize, text: &str, meta: &Meta) -> Result<(), AnnotError> { + let (_, r) = annot_ref(doc, page, index)?; + let free_text = annot_dict(doc, r).name(b"Subtype") == Some(b"FreeText"); + doc.update_dict(r, |d| { + d.set(b"Contents".to_vec(), PdfString::text(text)); + // The rich-text version follows the new plain text (text boxes), or would contradict it. + if free_text { + rich_text(d); + } else { + d.remove(b"RC"); + } + touch(d, meta); + })?; + if free_text { + // The box grows and shrinks with the text instead of clipping it. A locked box keeps its + // text editable (as in Acrobat), so the re-fit ignores the lock. New text is drawn even + // where the standard fonts can't draw some of it (marked and reported, see + // `draw_appearance`). + match fitted_box(doc, r, text) { + Some(rect) => apply_text_box(doc, r, rect, meta, true)?, + None => draw_appearance(doc, r)?, + } + } + Ok(()) +} + +/// The rectangle a FreeText annotation's text box needs for `text`: the current wrap width and +/// top edge stay and the height fits the wrapped lines, as at creation. `None` keeps the old +/// rectangle (the box can't be measured, so only the appearance is redrawn). +fn fitted_box(doc: &Document, r: ObjRef, text: &str) -> Option<[f64; 4]> { + let d = annot_dict(doc, r); + let nums = |key: &[u8]| -> Option> { d.get(key)?.as_array()?.iter().map(|o| o.as_f64()).collect() }; + let (_, size) = appearance::parse_da(&d); + let pad = 2.0 + border_width_of(&d); + let rect = nums(b"Rect").filter(|v| v.len() == 4 && v.iter().all(|x| x.is_finite()))?; + let rect = [rect[0], rect[1], rect[2], rect[3]]; + // A callout re-fits its text box (`/Rect` inset by `/RD`); the leader line keeps its place. + let tb = if d.contains(b"CL") { + let rd = nums(b"RD").filter(|v| v.len() == 4 && v.iter().all(|x| *x >= 0.0))?; + [rect[0] + rd[0], rect[1] + rd[1], rect[2] - rd[2], rect[3] - rd[3]] + } else { + rect + }; + let (x0, top) = (tb[0].min(tb[2]), tb[1].max(tb[3])); + let w = tb[2] - tb[0]; + let w = if w.is_finite() && w >= 1.0 { + w + } else { + // No usable old width: fall back to the creation-time width (at most 300 pt). + let longest = text.lines().map(|l| appearance::text_width(l, size)).fold(0.0, f64::max); + (longest + 2.0 * pad + 4.0).clamp(40.0, 300.0) + }; + let lines = appearance::wrap(text, size, (w - 2.0 * pad).max(1.0)).len().max(1) as f64; + Some([x0, top - lines * size * 1.2 - 2.0 * pad - 2.0, x0 + w, top]) +} + +/// Reply to a comment; returns the reply's index in the page's `/Annots`. +/// +/// A reply is a text annotation with `/IRT` pointing at its parent (§12.5.6.2). It gets the +/// parent's rectangle and an empty appearance, so it shows in comment lists but never paints a +/// second icon on the page. +pub fn add_reply(doc: &mut Document, page: usize, index: usize, text: &str, author: &str, meta: &Meta) -> Result { + reply(doc, page, index, text, author, meta, None) +} + +/// Set a comment's review status (Acrobat: "Set status ▸ Accepted"…). Like Acrobat this adds a +/// state reply (`/State`, `/StateModel /Review`) by `author`; the latest one wins. +pub fn set_review_state(doc: &mut Document, page: usize, index: usize, state: ReviewState, author: &str, meta: &Meta) -> Result { + let text = format!("{} set by {}", state.name(), if author.is_empty() { "unknown" } else { author }); + reply(doc, page, index, &text, author, meta, Some(state)) +} + +fn reply( + doc: &mut Document, + page: usize, + index: usize, + text: &str, + author: &str, + meta: &Meta, + state: Option, +) -> Result { + let (p, parent) = annot_ref(doc, page, index)?; + let pd = annot_dict(doc, parent); + if pd.name(b"Subtype") == Some(b"Popup") { + return Err(AnnotError::Invalid("pop-ups can't be replied to".into())); + } + let rect = pd.get(b"Rect").cloned().unwrap_or_else(|| num_array(&[0.0, 0.0, 0.0, 0.0])); + let mut d = base_dict("Text", [0.0; 4], p, text, author, meta); + d.set(b"Rect".to_vec(), rect); + d.set(b"IRT".to_vec(), Object::Ref(parent)); + d.set(b"F".to_vec(), Object::Int(FLAG_PRINT | FLAG_NO_ZOOM | FLAG_NO_ROTATE)); + d.set(b"Name".to_vec(), Object::name("Comment")); + if let Some(c) = pd.get(b"C") { + d.set(b"C".to_vec(), c.clone()); + } + if let Some(s) = state { + d.set(b"State".to_vec(), PdfString::text(s.name())); + d.set(b"StateModel".to_vec(), PdfString::text("Review")); + d.set(b"Subj".to_vec(), PdfString::text("Status")); + } + // An empty form: nothing is drawn for the reply itself. + let mut fd = Dict::new(); + fd.set(b"Type".to_vec(), Object::name("XObject")); + fd.set(b"Subtype".to_vec(), Object::name("Form")); + fd.set(b"BBox".to_vec(), num_array(&[0.0, 0.0, 0.0, 0.0])); + let ap = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(fd, Vec::new()))); + let mut apd = Dict::new(); + apd.set(b"N".to_vec(), Object::Ref(ap)); + d.set(b"AP".to_vec(), Object::Dict(apd)); + let r = doc.add(Object::Dict(d)); + let mut list = annots(doc, p); + list.push(Object::Ref(r)); + let i = list.len() - 1; + set_annots(doc, p, list)?; + Ok(i) +} + +fn flags(doc: &Document, r: ObjRef) -> i64 { + doc.get(r).as_dict().map(|d| annotation_flags(doc, d)).unwrap_or(0) +} + +/// Refuse to change a locked comment (Acrobat: Properties ▸ Locked). +pub(crate) fn unlocked(doc: &Document, r: ObjRef) -> Result<(), AnnotError> { + if flags(doc, r) & FLAG_LOCKED != 0 { Err(AnnotError::Invalid("the comment is locked".into())) } else { Ok(()) } +} + +/// Lock or unlock a comment (the Locked flag). A locked comment can't be moved, resized, +/// restyled or deleted; its text and replies stay editable, as in Acrobat. +pub fn set_locked(doc: &mut Document, page: usize, index: usize, locked: bool) -> Result<(), AnnotError> { + let (_, r) = annot_ref(doc, page, index)?; + let f = if locked { flags(doc, r) | FLAG_LOCKED } else { flags(doc, r) & !FLAG_LOCKED }; + doc.update_dict(r, |d| d.set(b"F".to_vec(), Object::Int(f)))?; + Ok(()) +} + +/// Mark or unmark a comment with a checkmark. Like Acrobat this adds a hidden state reply with +/// `/StateModel /Marked` by `author`; the latest one wins. It's private bookkeeping, not a status. +pub fn set_marked(doc: &mut Document, page: usize, index: usize, marked: bool, author: &str, meta: &Meta) -> Result { + let (p, parent) = annot_ref(doc, page, index)?; + let pd = annot_dict(doc, parent); + if pd.name(b"Subtype") == Some(b"Popup") { + return Err(AnnotError::Invalid("pop-ups can't be marked".into())); + } + let state = if marked { "Marked" } else { "Unmarked" }; + let text = format!("{state} set by {}", if author.is_empty() { "unknown" } else { author }); + let mut d = base_dict("Text", [0.0; 4], p, &text, author, meta); + d.set(b"Rect".to_vec(), pd.get(b"Rect").cloned().unwrap_or_else(|| num_array(&[0.0; 4]))); + d.set(b"IRT".to_vec(), Object::Ref(parent)); + d.set(b"F".to_vec(), Object::Int(FLAG_HIDDEN | FLAG_PRINT | FLAG_NO_ZOOM | FLAG_NO_ROTATE)); + d.set(b"State".to_vec(), PdfString::text(state)); + d.set(b"StateModel".to_vec(), PdfString::text("Marked")); + let r = doc.add(Object::Dict(d)); + let mut list = annots(doc, p); + list.push(Object::Ref(r)); + let i = list.len() - 1; + set_annots(doc, p, list)?; + Ok(i) +} + +/// Move a comment (and its pop-up) by `(dx, dy)` points. The appearance moves with `/Rect`. +pub fn move_annotation(doc: &mut Document, page: usize, index: usize, dx: f64, dy: f64, meta: &Meta) -> Result<(), AnnotError> { + if !finite(&[dx, dy]) { + return Err(AnnotError::Invalid("invalid offset".into())); + } + let (_, r) = annot_ref(doc, page, index)?; + unlocked(doc, r)?; + let shift = |o: &Object, every: bool| -> Option { + let a = o.as_array()?; + Some(Object::Array( + a.iter() + .enumerate() + .map(|(i, v)| match v.as_f64() { + Some(x) if every || i < 4 => Object::Real(x + if i % 2 == 0 { dx } else { dy }), + _ => v.clone(), + }) + .collect(), + )) + }; + let popup = annot_dict(doc, r).reference(b"Popup"); + doc.update_dict(r, |d| { + for (k, every) in [(&b"Rect"[..], false), (b"QuadPoints", true), (b"L", false), (b"CL", true), (b"Vertices", true)] { + if let Some(v) = d.get(k).and_then(|o| shift(o, every)) { + d.set(k.to_vec(), v); + } + } + if let Some(Object::Array(list)) = d.get(b"InkList").cloned() { + let moved = list.iter().map(|s| shift(s, true).unwrap_or_else(|| s.clone())).collect(); + d.set(b"InkList".to_vec(), Object::Array(moved)); + } + touch(d, meta); + })?; + if let Some(p) = popup + && doc.get(p).as_dict().is_some() + { + doc.update_dict(p, |d| { + if let Some(v) = d.get(b"Rect").and_then(|o| shift(o, false)) { + d.set(b"Rect".to_vec(), v); + } + })?; + } + Ok(()) +} + +/// Resize a rectangle, oval, text box, stamp or drawing to `rect`. Stamps keep their appearance, +/// which PDF viewers scale from its bounding box into the new rectangle; drawings scale their +/// strokes and get a new one. +pub fn set_rect(doc: &mut Document, page: usize, index: usize, rect: [f64; 4], meta: &Meta) -> Result<(), AnnotError> { + let (_, r) = annot_ref(doc, page, index)?; + unlocked(doc, r)?; + let d = annot_dict(doc, r); + let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned(); + if subtype == "Stamp" { + let rect = normalize(rect); + if !finite(&rect) || rect[2] <= rect[0] || rect[3] <= rect[1] { + return Err(AnnotError::Invalid("invalid rectangle (too small)".into())); + } + doc.update_dict(r, |d| { + d.set(b"Rect".to_vec(), num_array(&rect)); + touch(d, meta); + })?; + return Ok(()); + } + if subtype == "Ink" { + return resize_ink(doc, r, normalize(rect), meta); + } + if !matches!(subtype.as_str(), "Square" | "Circle" | "FreeText") { + return Err(AnnotError::Invalid(format!("{subtype} comments can't be resized"))); + } + apply_text_box(doc, r, rect, meta, false) +} + +/// At most this many points reshape a comment (a drawn polygon has a handful). +const MAX_POINTS: usize = 100_000; + +/// Reshape a line, connected lines, a polygon or a cloud: its points in user space (a line's two +/// ends, the others' corners), as dragging the comment's handles sets them. `/Rect` follows the +/// points with room for the line's width and endings, as when the comment was drawn, and the +/// appearance is redrawn. A measurement keeps the points it measured. +pub fn set_points(doc: &mut Document, page: usize, index: usize, points: &[[f64; 2]], meta: &Meta) -> Result<(), AnnotError> { + let (_, r) = annot_ref(doc, page, index)?; + unlocked(doc, r)?; + let d = annot_dict(doc, r); + if d.contains(b"Measure") { + return Err(AnnotError::Invalid("a measurement's points are where it measured".into())); + } + if points.len() > MAX_POINTS { + return Err(AnnotError::Invalid(format!("at most {MAX_POINTS} points"))); + } + let style = Style { width: border_width_of(&d), ..Style::default() }; + let ends = line_endings_of(&d).unwrap_or_default(); + let (start, end) = (ends.first().copied().unwrap_or(LineEnding::None), ends.get(1).copied().unwrap_or(LineEnding::None)); + let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned(); + let (shape, key): (Shape, &[u8]) = match (subtype.as_str(), points) { + ("Line", [from, to]) => (Shape::Line { from: *from, to: *to, start, end }, b"L"), + ("Line", _) => return Err(AnnotError::Invalid("a line has two ends".into())), + ("PolyLine", _) => (Shape::PolyLine { vertices: points.to_vec(), start, end }, b"Vertices"), + ("Polygon", _) => { + let cloud = d.get(b"BE").and_then(|b| b.as_dict()).is_some_and(|b| b.name(b"S") == Some(b"C")); + (Shape::Polygon { vertices: points.to_vec(), cloud }, b"Vertices") + } + (other, _) => return Err(AnnotError::Invalid(format!("{other} comments have no points to move"))), + }; + // Checks the points too: finite, and far enough apart to draw. + let rect = rect_for(&shape, &style)?; + let before = d; + doc.update_dict(r, |d| { + d.set(key.to_vec(), num_array(&points.concat())); + d.set(b"Rect".to_vec(), num_array(&rect)); + touch(d, meta); + })?; + redraw_or_restore(doc, r, before) +} + +/// Scale a drawing's strokes into `rect`. The margin between the strokes and the old `/Rect` (half +/// the line width and a little more) stays the same on each side, so the line isn't clipped. +fn resize_ink(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta) -> Result<(), AnnotError> { + let d = annot_dict(doc, r); + let strokes: Vec> = d + .get(b"InkList") + .map(|l| doc.resolve(l)) + .and_then(|l| l.as_array().cloned()) + .unwrap_or_default() + .iter() + .map(|s| { + let v: Vec = doc.resolve(s).as_array().map(|a| a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect()).unwrap_or_default(); + v.as_chunks::<2>().0.iter().map(|p| [p[0], p[1]]).collect() + }) + .collect(); + let Some(inner) = bounds(strokes.iter().flatten().copied()) else { + return Err(AnnotError::Invalid("the drawing has no strokes".into())); + }; + let old = d.get(b"Rect").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); + let old: Vec = old.iter().filter_map(|v| doc.resolve(v).as_f64()).collect(); + let old = match old[..] { + [a, b, c, e] => normalize([a, b, c, e]), + _ => inner, + }; + let margin = [inner[0] - old[0], inner[1] - old[1], old[2] - inner[2], old[3] - inner[3]].map(|m| m.max(0.0)); + let target = [rect[0] + margin[0], rect[1] + margin[1], rect[2] - margin[2], rect[3] - margin[3]]; + if !finite(&rect) || target[2] < target[0] || target[3] < target[1] || rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 { + return Err(AnnotError::Invalid("invalid rectangle (too small)".into())); + } + // An axis with no extent (a straight stroke) is centred rather than scaled. + let axis = |v: f64, a0: f64, a1: f64, b0: f64, b1: f64| if a1 > a0 { b0 + (v - a0) / (a1 - a0) * (b1 - b0) } else { (b0 + b1) / 2.0 }; + let scaled: Vec> = strokes + .iter() + .map(|s| { + s.iter().map(|p| [axis(p[0], inner[0], inner[2], target[0], target[2]), axis(p[1], inner[1], inner[3], target[1], target[3])]).collect() + }) + .collect(); + let before = annot_dict(doc, r); + doc.update_dict(r, |d| { + d.set(b"InkList".to_vec(), Object::Array(scaled.iter().map(|s| num_array(&s.concat())).collect())); + d.set(b"Rect".to_vec(), num_array(&rect)); + touch(d, meta); + })?; + redraw_or_restore(doc, r, before) +} + +/// Store `rect` as a FreeText annotation's text box (or a square's/oval's rectangle): a callout's +/// leader line re-attaches and `/Rect` grows to hold it, and the appearance is redrawn +/// (`new_text`: for changed contents, see [`set_appearance`] and `draw_appearance`). +fn apply_text_box(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta, new_text: bool) -> Result<(), AnnotError> { + let rect = normalize(rect); + if !finite(&rect) || rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 { + return Err(AnnotError::Invalid("invalid rectangle (too small)".into())); + } + let d = annot_dict(doc, r); + if !new_text { + refuse_foreign_text(doc, &d)?; + } + // A callout's rectangle is its text box: the leader line re-attaches and `/Rect` grows to hold it. + let callout = d.get(b"CL").and_then(|o| o.as_array()).map(|a| a.iter().filter_map(|x| x.as_f64()).collect::>()).filter(|l| l.len() == 6); + let (outer, cl) = match callout { + Some(mut l) => { + let attach = callout_attach(rect, [l[2], l[3]]); + (l[4], l[5]) = (attach[0], attach[1]); + let pad = border_width_of(&d) / 2.0 + appearance::arrow_size(border_width_of(&d)) + 1.0; + let lead = grow(bounds([[l[0], l[1]], [l[2], l[3]]].into_iter()).unwrap_or_default(), pad); + ([rect[0].min(lead[0]), rect[1].min(lead[1]), rect[2].max(lead[2]), rect[3].max(lead[3])], Some(l)) + } + None => (rect, None), + }; + doc.update_dict(r, |d| { + d.set(b"Rect".to_vec(), num_array(&outer)); + match &cl { + Some(l) => { + d.set(b"CL".to_vec(), num_array(l)); + d.set(b"RD".to_vec(), num_array(&[rect[0] - outer[0], rect[1] - outer[1], outer[2] - rect[2], outer[3] - rect[3]])); + } + None => { + d.remove(b"RD"); + } + } + touch(d, meta); + })?; + let before = d; + if new_text { + if let Err(e) = draw_appearance(doc, r) { + doc.set(r, Object::Dict(before)); + return Err(e); + } + Ok(()) + } else { + redraw_or_restore(doc, r, before) + } +} + +fn border_width_of(d: &Dict) -> f64 { + appearance::border_width(d) +} + +/// Recompute `/Rect` for an `Ink` annotation when its stroke width changes from `old_w` to `w`. +/// Preserves any baseline margin beyond `old_w / 2.0` (at least `1.0` pt) without accumulating +/// padding across repeated width increases and decreases. +fn ink_rect_for_width(d: &Dict, old_w: f64, w: f64) -> Option<[f64; 4]> { + let list = d.get(b"InkList")?.as_array()?; + let mut strokes: Vec> = Vec::with_capacity(list.len()); + for s in list { + let pts: Vec = s.as_array()?.iter().map(Object::as_f64).collect::>()?; + strokes.push(pts.as_chunks::<2>().0.iter().map(|p| [p[0], p[1]]).collect()); + } + let inner = ink_bounds(&strokes)?; + let vals: Vec = d.get(b"Rect").and_then(Object::as_array).map(|a| a.iter().filter_map(Object::as_f64).collect()).unwrap_or_default(); + let Some(old_rect) = four(&vals).map(normalize) else { + return Some(grow(inner, w / 2.0 + 1.0)); + }; + let old_half = old_w / 2.0; + let new_half = w / 2.0; + let base = [ + (inner[0] - old_rect[0] - old_half).max(1.0), + (inner[1] - old_rect[1] - old_half).max(1.0), + (old_rect[2] - inner[2] - old_half).max(1.0), + (old_rect[3] - inner[3] - old_half).max(1.0), + ]; + Some([inner[0] - base[0] - new_half, inner[1] - base[1] - new_half, inner[2] + base[2] + new_half, inner[3] + base[3] + new_half]) +} + +/// Change a comment's colour, opacity, line width and/or line endings, and redraw it. +pub fn set_style(doc: &mut Document, page: usize, index: usize, change: StyleChange, meta: &Meta) -> Result<(), AnnotError> { + let StyleChange { color, opacity, width, endings } = change; + let (_, r) = annot_ref(doc, page, index)?; + unlocked(doc, r)?; + let d = annot_dict(doc, r); + let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned(); + // Check before changing anything: a stale appearance would contradict the new style. + if appearance::build(&d).is_none() { + return Err(AnnotError::Unsupported(subtype.clone())); + } + if color.is_some_and(|c| !finite(&c)) || opacity.is_some_and(|o| !o.is_finite()) || width.is_some_and(|w| !w.is_finite()) { + return Err(AnnotError::Invalid("invalid style".into())); + } + if let Some(ends) = &endings { + let callout = subtype == "FreeText" && d.contains(b"CL"); + let pair = matches!(subtype.as_str(), "Line" | "PolyLine"); + if (callout && ends.len() != 1) || (pair && ends.len() != 2) || (!callout && !pair) { + return Err(AnnotError::Invalid("this comment has no line endings to change".into())); + } + } + refuse_foreign_text(doc, &d)?; + let free_text = subtype == "FreeText"; + let before = d; + doc.update_dict(r, |d| { + if let Some(c) = color { + if free_text { + let (_, size) = appearance::parse_da(d); + let [r, g, b] = c.map(|x| x.clamp(0.0, 1.0)); + d.set(b"DA".to_vec(), PdfString::literal(format!("{} {} {} rg /Helv {} Tf", n(r), n(g), n(b), n(size)).into_bytes())); + rich_text(d); + } else { + d.set(b"C".to_vec(), rgb(c)); + } + } + if let Some(o) = opacity { + let o = o.clamp(0.0, 1.0); + if o < 1.0 { + d.set(b"CA".to_vec(), Object::Real(o)); + } else { + d.remove(b"CA"); + } + } + if let Some(w) = width { + let old_w = border_width_of(d); + let w = w.max(0.0); + let mut bs = d.get(b"BS").and_then(|b| b.as_dict()).cloned().unwrap_or_default(); + bs.set(b"W".to_vec(), Object::Real(w)); + d.set(b"BS".to_vec(), Object::Dict(bs)); + d.remove(b"Border"); + if d.name(b"Subtype") == Some(b"Ink") + && let Some(rect) = ink_rect_for_width(d, old_w, w) + { + d.set(b"Rect".to_vec(), num_array(&rect)); + } + } + if let Some(ends) = endings { + if ends.len() == 1 { + if let Some(ending) = ends.first() { + d.set(b"LE".to_vec(), Object::name(ending.name())); + } + } else if let (Some(start), Some(end)) = (ends.first(), ends.get(1)) { + d.set(b"LE".to_vec(), ending_pair(*start, *end)); + } + } + ensure_ending_room(d); + touch(d, meta); + })?; + 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 has a fill: `/IC` for shapes, `/C` for FreeText (§12.5.6). +pub fn fillable(subtype: &str) -> bool { + matches!(subtype, "Square" | "Circle" | "Polygon" | "FreeText") +} + +/// Fill a shape or FreeText comment with `fill`, or remove its fill (`None`), and +/// redraw its appearance (Acrobat: Properties ▸ Fill Color). +pub fn set_fill(doc: &mut Document, page: usize, index: usize, fill: Option, meta: &Meta) -> Result<(), AnnotError> { + let (_, r) = annot_ref(doc, page, index)?; + unlocked(doc, r)?; + let d = annot_dict(doc, r); + let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned(); + if !fillable(&subtype) { + return Err(AnnotError::Invalid("only rectangles, ovals, polygons and text boxes can be filled".into())); + } + // Check before changing anything: a stale appearance would contradict the new fill. + if appearance::build(&d).is_none() { + return Err(AnnotError::Unsupported(subtype)); + } + if fill.is_some_and(|c| !finite(&c)) { + return Err(AnnotError::Invalid("invalid fill colour".into())); + } + refuse_foreign_text(doc, &d)?; + let before = d; + doc.update_dict(r, |d| { + let key: &[u8] = if subtype == "FreeText" { b"C" } else { b"IC" }; + match fill { + Some(c) => d.set(key.to_vec(), rgb(c)), + None => { + d.remove(key); + } + } + touch(d, meta); + })?; + redraw_or_restore(doc, r, before) +} + +/// Grow a line or polyline's rectangle so a non-`None` ending is not clipped. Idempotent. +fn ensure_ending_room(d: &mut Dict) { + let subtype = d.name(b"Subtype").unwrap_or_default(); + if !matches!(subtype, b"Line" | b"PolyLine") { + return; + } + if !line_endings_of(d).is_some_and(|ends| ends.iter().any(|e| ending_draws(*e))) { + return; + } + let key: &[u8] = if subtype == b"Line" { b"L" } else { b"Vertices" }; + let Some(pts) = pair_points(d, key) else { return }; + let Some(bounds) = bounds(pts.iter().copied()) else { return }; + let w = border_width_of(d); + let need = grow(bounds, w / 2.0 + appearance::arrow_size(w) + 1.0); + let vals: Vec = d.get(b"Rect").and_then(|o| o.as_array()).map(|a| a.iter().filter_map(|x| x.as_f64()).collect()).unwrap_or_default(); + let Some(rect) = four(&vals) else { return }; + let union = [rect[0].min(need[0]), rect[1].min(need[1]), rect[2].max(need[2]), rect[3].max(need[3])]; + if union != rect { + d.set(b"Rect".to_vec(), num_array(&union)); + } +} + +fn four(v: &[f64]) -> Option<[f64; 4]> { + Some([*v.first()?, *v.get(1)?, *v.get(2)?, *v.get(3)?]) +} + +fn pair_points(d: &Dict, key: &[u8]) -> Option> { + let v: Vec = d.get(key)?.as_array()?.iter().filter_map(|o| o.as_f64()).collect(); + if v.len() < 4 || !v.len().is_multiple_of(2) || v.iter().any(|x| !x.is_finite()) { + return None; + } + Some(v.as_chunks::<2>().0.to_vec()) +} + +/// Replace Text (Acrobat's proposal): strike out `quads` and add a caret at the end of the +/// struck text holding `replacement`, grouped with the strikeout (`/IRT` + `/RT /Group`) so they +/// move, list and delete as one. Returns the strikeout's index. +/// +/// The two annotations need their own `/NM`: XFDF names a reply by the `/NM` of what it replies +/// to, so one id for both writes `inreplyto` equal to the caret's own `name`, and a reader that +/// resolves names (Acrobat, and our own importer) loses the pair. `caret_meta` is a second id +/// from the caller, so the edit stays deterministic. +pub fn add_text_replacement(doc: &mut Document, replacement: TextReplacement<'_>) -> Result { + let TextReplacement { page, quads, replacement, author, strike, caret, strike_meta: meta, caret_meta } = replacement; + let last = *quads.last().ok_or_else(|| AnnotError::Invalid("select the text to replace first".into()))?; + let s = add_annotation( + doc, + &NewAnnotation { + page, + shape: Shape::TextMarkup { kind: Markup::StrikeOut, quads: quads.to_vec() }, + style: strike.clone(), + contents: String::new(), + author: author.into(), + }, + meta, + )?; + let (_, sr) = annot_ref(doc, page, s)?; + doc.update_dict(sr, |d| d.set(b"IT".to_vec(), Object::name("StrikeOutTextEdit")))?; + // The caret sits on the baseline at the end of the last line, its point at the text. + let xs = [last[0], last[2], last[4], last[6]]; + let ys = [last[1], last[3], last[5], last[7]]; + let x = xs.iter().copied().fold(f64::MIN, f64::max); + let (y0, y1) = (ys.iter().copied().fold(f64::MAX, f64::min), ys.iter().copied().fold(f64::MIN, f64::max)); + let h = ((y1 - y0) * 0.6).clamp(4.0, 24.0); + let rect = [x - h / 2.0, y0 - h * 0.6, x + h / 2.0, y0 + h * 0.4]; + let c = add_annotation( + doc, + &NewAnnotation { page, shape: Shape::Caret { rect }, style: caret.clone(), contents: replacement.into(), author: author.into() }, + caret_meta, + )?; + let (_, cr) = annot_ref(doc, page, c)?; + doc.update_dict(cr, |d| { + d.set(b"IRT".to_vec(), Object::Ref(sr)); + d.set(b"RT".to_vec(), Object::name("Group")); + d.set(b"Subj".to_vec(), PdfString::text("Replace Text")); + })?; + Ok(s) +} + +/// The Eraser (Draw tools): remove the parts of drawing `(page, index)` within `radius` points +/// of `path`, splitting strokes where they are cut. A drawing with nothing left is deleted. +/// Returns whether anything was erased. +pub fn erase_ink(doc: &mut Document, page: usize, index: usize, path: &[[f64; 2]], radius: f64, meta: &Meta) -> Result { + let (_, r) = annot_ref(doc, page, index)?; + unlocked(doc, r)?; + let d = annot_dict(doc, r); + if d.name(b"Subtype") != Some(b"Ink") { + return Err(AnnotError::Invalid("only drawings can be erased".into())); + } + if path.is_empty() || !path.iter().all(|p| finite(p)) || !radius.is_finite() || radius <= 0.0 { + return Err(AnnotError::Invalid("invalid eraser path".into())); + } + let strokes: Vec> = d + .get(b"InkList") + .map(|l| doc.resolve(l)) + .and_then(|l| l.as_array().cloned()) + .unwrap_or_default() + .iter() + .map(|s| { + let v: Vec = doc.resolve(s).as_array().map(|a| a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect()).unwrap_or_default(); + v.as_chunks::<2>().0.iter().map(|p| [p[0], p[1]]).collect() + }) + .collect(); + // Distance from a point to the eraser's path (segments, or a single point). + let near = |p: [f64; 2]| -> bool { + if path.len() == 1 { + return (p[0] - path[0][0]).hypot(p[1] - path[0][1]) <= radius; + } + path.windows(2).any(|w| { + let (a, b) = (w[0], w[1]); + let (dx, dy) = (b[0] - a[0], b[1] - a[1]); + let len2 = dx * dx + dy * dy; + let t = if len2 > 0.0 { (((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len2).clamp(0.0, 1.0) } else { 0.0 }; + (p[0] - (a[0] + t * dx)).hypot(p[1] - (a[1] + t * dy)) <= radius + }) + }; + let mut kept: Vec> = Vec::new(); + let mut erased = false; + for s in &strokes { + // Densify so long segments are cut where the eraser crosses them. + let mut pts: Vec<[f64; 2]> = Vec::new(); + for w in s.windows(2) { + let n = ((w[1][0] - w[0][0]).hypot(w[1][1] - w[0][1]) / (radius / 2.0)).ceil().clamp(1.0, 2000.0) as usize; + for k in 0..n { + let t = k as f64 / n as f64; + pts.push([w[0][0] + (w[1][0] - w[0][0]) * t, w[0][1] + (w[1][1] - w[0][1]) * t]); + } + } + if let Some(last) = s.last() { + pts.push(*last); + } + let mut run: Vec<[f64; 2]> = Vec::new(); + for p in pts { + if near(p) { + erased = true; + if run.len() >= 2 { + kept.push(std::mem::take(&mut run)); + } + run.clear(); + } else { + run.push(p); + } + } + if run.len() >= 2 { + kept.push(run); + } + } + if !erased { + return Ok(false); + } + if kept.is_empty() { + delete_annotation(doc, page, index)?; + return Ok(true); + } + let width = d.get(b"BS").and_then(|b| b.as_dict()).and_then(|b| b.get(b"W")).and_then(|w| w.as_f64()).unwrap_or(1.0); + let rect = grow(bounds(kept.iter().flatten().copied()).unwrap_or_default(), width / 2.0 + 1.0); + doc.update_dict(r, |d| { + d.set(b"InkList".to_vec(), Object::Array(kept.iter().map(|s| num_array(&s.concat())).collect())); + d.set(b"Rect".to_vec(), num_array(&rect)); + touch(d, meta); + })?; + set_appearance(doc, r)?; + Ok(true) +} diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index b9e47e5fb9..32d28fc6ec 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -229,6 +229,34 @@ fn delete_takes_popup_and_reply_chain() { assert_eq!(delete_annotation(&mut doc, 0, 9), Err(AnnotError::NoSuchAnnotation { page: 0, index: 9 })); } +/// A locked reply can't be deleted, so it doesn't go with what it answers either: deleting the +/// reply above it, or the comment, is refused and changes nothing; once it is unlocked they go. +#[test] +fn a_locked_reply_keeps_what_it_answers() { + let mut doc = fixture(); + let note = add_annotation(&mut doc, &new(0, Shape::Note { at: [100.0, 100.0], icon: NoteIcon::Comment }), &meta("n")).unwrap(); + let reply = add_reply(&mut doc, 0, note, "first", "Bob", &meta("r1")).unwrap(); + let answer = add_reply(&mut doc, 0, reply, "locked answer", "Ada", &meta("r2")).unwrap(); + set_locked(&mut doc, 0, answer, true).unwrap(); + let before = list(&doc, 0).len(); + assert_eq!(delete_annotation(&mut doc, 0, reply), Err(AnnotError::Invalid("a reply to it is locked".into()))); + assert_eq!(delete_annotation(&mut doc, 0, note), Err(AnnotError::Invalid("a reply to it is locked".into()))); + assert_eq!(list(&doc, 0).len(), before, "nothing went"); + // Asked first, read-only, as a Delete command does: the same answer, and the answer's own + // lock is its own. + assert_eq!(deletable(&doc, 0, note), Err(AnnotError::Invalid("a reply to it is locked".into()))); + assert_eq!(deletable(&doc, 0, answer), Err(AnnotError::Invalid("the comment is locked".into()))); + // Lifted on a copy, to draw the page beneath, the locks don't count. + let mut copy = doc.clone(); + lift_annotation(&mut copy, 0, note).unwrap(); + assert!(list(©, 0).len() < before, "the thread is lifted off the copy"); + set_locked(&mut doc, 0, answer, false).unwrap(); + assert_eq!(deletable(&doc, 0, reply), Ok(())); + delete_annotation(&mut doc, 0, reply).unwrap(); + let left: Vec = list(&doc, 0).iter().map(|d| text(d, b"Contents")).collect(); + assert!(!left.iter().any(|t| t == "first" || t == "locked answer"), "the reply and its answer went: {left:?}"); +} + /// A reply or pop-up written inline in `/Annots` (a file can, though the standard asks for /// references) goes with the comment it points at, and so does a pop-up an inline reply or an /// inline comment names. An unrelated inline comment stays until it is deleted itself. @@ -303,7 +331,7 @@ fn move_shifts_all_geometry_and_the_popup() { fn restyle_and_resize_redraw_the_appearance() { let mut doc = fixture(); let r = add_annotation(&mut doc, &new(0, Shape::Rectangle { rect: [10.0, 10.0, 110.0, 60.0] }), &meta("r")).unwrap(); - set_style(&mut doc, 0, r, Some([1.0, 0.0, 0.0]), Some(0.5), Some(4.0), None, &meta("")).unwrap(); + set_style(&mut doc, 0, r, StyleChange::new(Some([1.0, 0.0, 0.0]), Some(0.5), Some(4.0), None), &meta("")).unwrap(); set_rect(&mut doc, 0, r, [0.0, 0.0, 200.0, 100.0], &meta("")).unwrap(); let doc2 = reopen(&doc); let d = &list(&doc2, 0)[r]; @@ -313,7 +341,10 @@ fn restyle_and_resize_redraw_the_appearance() { assert_eq!(d.get(b"CA").and_then(|o| o.as_f64()), Some(0.5)); // A stamp's appearance can't be regenerated, so restyling it changes nothing. let before = list(&doc, 1)[0].clone(); - assert_eq!(set_style(&mut doc, 1, 0, Some([0.0; 3]), None, None, None, &meta("")), Err(AnnotError::Unsupported("Stamp".into()))); + assert_eq!( + set_style(&mut doc, 1, 0, StyleChange::new(Some([0.0; 3]), None, None, None), &meta("")), + Err(AnnotError::Unsupported("Stamp".into())) + ); assert_eq!(list(&doc, 1)[0], before); // Lines can't be resized as rectangles. let l = add_annotation( @@ -374,7 +405,7 @@ fn text_box_text_and_colour_changes_are_drawn() { let mut doc = fixture(); let t = add_annotation(&mut doc, &new(0, Shape::TextBox { rect: [0.0, 0.0, 300.0, 100.0], font_size: 12.0 }), &meta("t")).unwrap(); set_contents(&mut doc, 0, t, "Caf\u{e9} (draft) \u{2014} 100%", &meta("")).unwrap(); - set_style(&mut doc, 0, t, Some([1.0, 0.0, 0.0]), None, None, None, &meta("")).unwrap(); + set_style(&mut doc, 0, t, StyleChange::new(Some([1.0, 0.0, 0.0]), None, None, None), &meta("")).unwrap(); let doc = reopen(&doc); let d = &list(&doc, 0)[t]; assert_eq!(text(d, b"Contents"), "Caf\u{e9} (draft) \u{2014} 100%"); @@ -556,8 +587,8 @@ fn fill_and_sign_items_are_drawn() { assert_eq!(text(&all[s], b"Subj"), "Signature"); // Other stamps still can't be restyled; ours can. let mut doc = doc; - set_style(&mut doc, 1, marks[0], Some([0.0, 0.0, 1.0]), None, None, None, &meta("")).unwrap(); - assert!(set_style(&mut doc, 1, 0, Some([0.0; 3]), None, None, None, &meta("")).is_err()); + set_style(&mut doc, 1, marks[0], StyleChange::new(Some([0.0, 0.0, 1.0]), None, None, None), &meta("")).unwrap(); + assert!(set_style(&mut doc, 1, 0, StyleChange::new(Some([0.0; 3]), None, None, None), &meta("")).is_err()); } #[test] @@ -844,7 +875,20 @@ fn replace_text_groups_a_strikeout_and_a_caret() { let quads = [[100.0, 720.0, 200.0, 720.0, 100.0, 706.0, 200.0, 706.0]]; let red = Style { color: [0.89, 0.13, 0.13], opacity: 1.0, width: 1.0, fill: None }; let blue = Style { color: [0.0, 0.47, 0.84], opacity: 1.0, width: 1.0, fill: None }; - let i = add_text_replacement(&mut doc, 0, &quads, "new words", "Ada", &red, &blue, &meta("r"), &meta("r-caret")).unwrap(); + let i = add_text_replacement( + &mut doc, + TextReplacement { + page: 0, + quads: &quads, + replacement: "new words", + author: "Ada", + strike: &red, + caret: &blue, + strike_meta: &meta("r"), + caret_meta: &meta("r-caret"), + }, + ) + .unwrap(); let doc = reopen(&doc); let a = list(&doc, 0); let strike = &a[i]; @@ -866,7 +910,22 @@ fn replace_text_groups_a_strikeout_and_a_caret() { let mut doc = doc; delete_annotation(&mut doc, 0, i).unwrap(); assert!(!list(&doc, 0).iter().any(|d| d.name(b"Subtype") == Some(b"Caret"))); - assert!(add_text_replacement(&mut doc, 0, &[], "x", "Ada", &red, &blue, &meta("r"), &meta("r-caret")).is_err()); + assert!( + add_text_replacement( + &mut doc, + TextReplacement { + page: 0, + quads: &[], + replacement: "x", + author: "Ada", + strike: &red, + caret: &blue, + strike_meta: &meta("r"), + caret_meta: &meta("r-caret") + } + ) + .is_err() + ); } #[test] @@ -1177,7 +1236,7 @@ fn every_line_ending_is_drawn() { ) .unwrap(); let before = rect(&list(&doc, 0).into_iter().find(|d| d.name(b"Subtype") == Some(b"Line")).expect("line")); - set_style(&mut doc, 0, index, None, None, None, Some(&[LineEnding::None, LineEnding::Square]), &meta("style")).unwrap(); + set_style(&mut doc, 0, index, StyleChange::new(None, None, None, Some(vec![LineEnding::None, LineEnding::Square])), &meta("style")).unwrap(); let mut doc = reopen(&doc); let line = list(&doc, 0).into_iter().find(|d| d.name(b"Subtype") == Some(b"Line")).expect("line"); let after = rect(&line); @@ -1245,7 +1304,7 @@ fn redraw_keeps_shared_annotation_appearances_and_drops_stale_alternates() { let other_dict = list(&doc, 0)[other].clone(); let source = other_dict.get(b"AP").unwrap(); let before = doc.resolve(source); - set_style(&mut doc, 0, first, Some([1.0, 0.0, 0.0]), None, None, None, &meta("")).unwrap(); + set_style(&mut doc, 0, first, StyleChange::new(Some([1.0, 0.0, 0.0]), None, None, None), &meta("")).unwrap(); assert_eq!(doc.resolve(source), before, "a shared AP must not change"); for full in [false, true] { let bytes = if full { @@ -1367,7 +1426,7 @@ fn comment_text_the_standard_fonts_cannot_draw_is_kept_and_marked() { .unwrap(); assert_eq!(mark(&doc, s), Some('张')); // Our own `?` appearance is redrawn on a restyle; Western European text clears the mark. - set_style(&mut doc, 0, t, Some([1.0, 0.0, 0.0]), None, None, None, &meta("")).unwrap(); + set_style(&mut doc, 0, t, StyleChange::new(Some([1.0, 0.0, 0.0]), None, None, None), &meta("")).unwrap(); assert_eq!(mark(&doc, t), Some('签')); set_contents(&mut doc, 0, t, "Zürich —\nGenève", &meta("")).unwrap(); assert_eq!(mark(&doc, t), None); @@ -1395,7 +1454,7 @@ fn comment_text_the_standard_fonts_cannot_draw_is_kept_and_marked() { set_annots(&mut doc, page, on_page).unwrap(); let k = list(&doc, 0).len() - 1; let before = list(&doc, 0)[k].clone(); - let err = set_style(&mut doc, 0, k, Some([1.0, 0.0, 0.0]), Some(0.4), Some(6.0), None, &meta("")).unwrap_err(); + let err = set_style(&mut doc, 0, k, StyleChange::new(Some([1.0, 0.0, 0.0]), Some(0.4), Some(6.0), None), &meta("")).unwrap_err(); assert!(matches!(&err, AnnotError::Undrawable(m) if m.contains("can't redraw") && m.contains("\"欢\" (U+6B22)")), "{err:?}"); assert_eq!(list(&doc, 0)[k], before, "a refused restyle keeps the colour, opacity, width and appearance"); let err = set_rect(&mut doc, 0, k, [40.0, 40.0, 180.0, 90.0], &meta("")).unwrap_err(); @@ -1476,7 +1535,7 @@ fn a_comment_listed_many_times_is_drawn_once_for_display() { 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_style(&mut doc, 0, t, StyleChange::new(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(); @@ -1522,7 +1581,7 @@ fn text_boxes_and_callouts_can_change_fill_and_border() { let index = add_annotation(&mut doc, &new(0, shape), &meta("test")).unwrap(); assert!(props(&doc, 0, index).unwrap().fillable); set_fill(&mut doc, 0, index, Some([1.0, 1.0, 1.0]), &meta("test")).unwrap(); - set_style(&mut doc, 0, index, None, None, Some(3.0), None, &meta("test")).unwrap(); + set_style(&mut doc, 0, index, StyleChange::new(None, None, Some(3.0), None), &meta("test")).unwrap(); let saved = reopen(&doc); let properties = props(&saved, 0, index).unwrap(); assert_eq!(properties.fill, Some([1.0; 3])); @@ -1791,7 +1850,7 @@ fn a_star_note_keeps_its_star_when_recoloured() { b"<< /Type /Annot /Subtype /Text /Rect [100 100 120 120] /Name /Star /C [1 0.82 0] /F 4 >>", ]); assert_eq!(props(&doc, 0, 0).and_then(|p| p.icon), Some(NoteIcon::Star)); - set_style(&mut doc, 0, 0, Some([0.0, 0.0, 1.0]), None, None, None, &meta("")).unwrap(); + set_style(&mut doc, 0, 0, StyleChange::new(Some([0.0, 0.0, 1.0]), None, None, None), &meta("")).unwrap(); let doc = reopen(&doc); let d = &list(&doc, 0)[0]; assert_eq!(d.name(b"Name"), Some(&b"Star"[..])); @@ -2135,3 +2194,83 @@ fn degenerate_and_absurd_leader_and_caption_values_do_not_panic() { assert!(long.contains(&format!("({}) Tj", "w".repeat(256))), "cut to 256"); assert!(!long.contains(&"w".repeat(257))); } + +#[test] +fn freehand_highlight_is_compatible_ink_with_marker_blending() { + let shape = Shape::FreehandHighlight { strokes: vec![vec![[20.0, 100.0], [120.0, 100.0]]] }; + let mut annotation = new(0, shape.clone()); + annotation.style = Style::default_for(&shape); + let mut doc = fixture(); + let index = add_annotation(&mut doc, &annotation, &meta("marker")).unwrap(); + let doc = reopen(&doc); + let ink = &list(&doc, 0)[index]; + + assert_eq!(ink.name(b"Subtype"), Some(&b"Ink"[..])); + assert_eq!(ink.name(b"IT"), Some(&b"FreehandHighlight"[..])); + assert_eq!(ink.get(b"CA").and_then(Object::as_f64), Some(0.35)); + let ap = ink.get(b"AP").unwrap().as_dict().unwrap().reference(b"N").unwrap(); + let resources = doc.get(ap).as_dict().unwrap().get(b"Resources").cloned().unwrap(); + let blend = resources.as_dict().unwrap().get(b"ExtGState").unwrap().as_dict().unwrap().get(b"GS0").unwrap().as_dict().unwrap(); + assert_eq!(blend.name(b"BM"), Some(&b"Multiply"[..])); + + let mut doc = doc; + let rect_of = |doc: &Document, idx: usize| -> Vec { + list(doc, 0)[idx].get(b"Rect").unwrap().as_array().unwrap().iter().filter_map(Object::as_f64).collect() + }; + let rect_before = rect_of(&doc, index); + set_style(&mut doc, 0, index, StyleChange { color: None, opacity: None, width: Some(34.0), endings: None }, &meta("wider")).unwrap(); + let rect_after = rect_of(&doc, index); + assert!((rect_after[0] - (rect_before[0] - 10.0)).abs() < 1e-4); + assert!((rect_after[2] - (rect_before[2] + 10.0)).abs() < 1e-4); + + // Repeated increase/decrease cycles must keep bounds stable and never accumulate padding. + set_style(&mut doc, 0, index, StyleChange { color: None, opacity: None, width: Some(14.0), endings: None }, &meta("narrower")).unwrap(); + let rect_restored = rect_of(&doc, index); + for (a, b) in rect_restored.iter().zip(rect_before.iter()) { + assert!((a - b).abs() < 1e-4, "expected {rect_before:?}, got {rect_restored:?}"); + } + set_style(&mut doc, 0, index, StyleChange { color: None, opacity: None, width: Some(34.0), endings: None }, &meta("wider-again")).unwrap(); + let rect_second_wide = rect_of(&doc, index); + for (a, b) in rect_second_wide.iter().zip(rect_after.iter()) { + assert!((a - b).abs() < 1e-4, "expected {rect_after:?}, got {rect_second_wide:?}"); + } + + // Imported ink whose width is specified only by `/Border [0 0 20]` must use 20 pt as its + // previous width (shrinking by 5 pt on a decrease to 10 pt rather than expanding as if 1 pt). + let (_, r) = lookup::annot_ref(&mut doc, 0, index).unwrap(); + doc.update_dict(r, |d| { + d.remove(b"BS"); + d.set(b"Border".to_vec(), Object::Array(vec![Object::Real(0.0), Object::Real(0.0), Object::Real(20.0)])); + d.set(b"Rect".to_vec(), create::num_array(&[5.0, 85.0, 135.0, 115.0])); + }) + .unwrap(); + set_style(&mut doc, 0, index, StyleChange { color: None, opacity: None, width: Some(10.0), endings: None }, &meta("border-shrink")).unwrap(); + let rect_from_border = rect_of(&doc, index); + for (a, b) in rect_from_border.iter().zip([10.0, 90.0, 130.0, 110.0]) { + assert!((a - b).abs() < 1e-4, "expected [10, 90, 130, 110], got {rect_from_border:?}"); + } +} + +#[test] +fn indirect_typewriter_names_keep_outer_whitespace_in_summaries() { + let mut doc = fixture(); + let intent = Object::Ref(doc.add(Object::name("FreeTextTypeWriter"))); + let page = page_refs(&doc).unwrap()[0]; + let mut entries = annots(&doc, page); + let Object::Dict(annotation) = &mut entries[0] else { panic!("fixture annotation is inline") }; + annotation.set(b"Subtype".to_vec(), Object::name("FreeText")); + annotation.set(b"IT".to_vec(), intent); + let contents = " \nAda\n\nLovelace\n\n "; + annotation.set(b"Contents".to_vec(), PdfString::text(contents)); + set_annots(&mut doc, page, entries).unwrap(); + let check = |doc: &Document| { + let list = summaries(doc); + let summary = list.iter().find(|a| a.page == 0 && a.index == 0).expect("direct Subtype is listed"); + assert_eq!(summary.contents.as_deref(), Some(contents), "indirect typewriter intent preserves outer whitespace"); + assert_eq!(summary.subtype, "FreeText"); + assert_eq!(summary.intent.as_deref(), Some("FreeTextTypeWriter")); + assert!(summary.fill_sign, "indirect intent still identifies a Fill & Sign mark"); + }; + check(&doc); + check(&reopen(&doc)); +} diff --git a/crates/annot/src/types.rs b/crates/annot/src/types.rs new file mode 100644 index 0000000000..522995b9f9 --- /dev/null +++ b/crates/annot/src/types.rs @@ -0,0 +1,696 @@ +use super::create::normalize; +use super::{ObjRef, appearance}; + +pub type Rgb = [f64; 3]; + +/// Text markup kinds (§12.5.6.10). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Markup { + Highlight, + Underline, + StrikeOut, + Squiggly, +} + +impl Markup { + fn subtype(self) -> &'static str { + match self { + Markup::Highlight => "Highlight", + Markup::Underline => "Underline", + Markup::StrikeOut => "StrikeOut", + Markup::Squiggly => "Squiggly", + } + } +} + +/// Text-annotation icon names (`/Name`). The first seven are the standard names ISO 32000-2 +/// §12.5.6.4, Table 175 asks every viewer to draw; the rest are further names in common use, which +/// the same table allows ("Additional names may be supported as well"). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum NoteIcon { + #[default] + Comment, + Note, + Help, + Insert, + Key, + NewParagraph, + Paragraph, + Check, + Circle, + Cross, + CrossHairs, + Star, + RightArrow, + UpArrow, +} + +impl NoteIcon { + pub fn name(self) -> &'static str { + match self { + NoteIcon::Comment => "Comment", + NoteIcon::Note => "Note", + NoteIcon::Help => "Help", + NoteIcon::Insert => "Insert", + NoteIcon::Key => "Key", + NoteIcon::NewParagraph => "NewParagraph", + NoteIcon::Paragraph => "Paragraph", + NoteIcon::Check => "Check", + NoteIcon::Circle => "Circle", + NoteIcon::Cross => "Cross", + NoteIcon::CrossHairs => "CrossHairs", + NoteIcon::Star => "Star", + NoteIcon::RightArrow => "RightArrow", + NoteIcon::UpArrow => "UpArrow", + } + } + + /// Every icon, the standard names first. + pub const ALL: [NoteIcon; 14] = [ + Self::Comment, + Self::Note, + Self::Help, + Self::Insert, + Self::Key, + Self::NewParagraph, + Self::Paragraph, + Self::Check, + Self::Circle, + Self::Cross, + Self::CrossHairs, + Self::Star, + Self::RightArrow, + Self::UpArrow, + ]; + + pub fn from_name(n: &str) -> Option { + Self::ALL.into_iter().find(|i| i.name() == n) + } +} + +/// Fill & Sign marks (Acrobat's ✓, ✕, ●, ─). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FillMark { + Check, + Cross, + Dot, + Line, +} + +impl FillMark { + /// The `/Name` of the stamp PdfCraft draws for it. + pub fn name(self) -> &'static str { + match self { + FillMark::Check => "PCCheck", + FillMark::Cross => "PCCross", + FillMark::Dot => "PCDot", + FillMark::Line => "PCLine", + } + } + + pub fn label(self) -> &'static str { + match self { + FillMark::Check => "Checkmark", + FillMark::Cross => "Cross", + FillMark::Dot => "Dot", + FillMark::Line => "Line", + } + } + + /// The user-space box of the mark placed at `at` on a page turned by `rotation`: centred + /// there and sized in displayed axes, so a line runs across the page as displayed (#2392). + pub fn rect_at(self, at: [f64; 2], rotation: i64) -> [f64; 4] { + let (w, h) = if self == FillMark::Line { (36.0, 4.0) } else { (12.0, 12.0) }; + let (w, h) = if matches!(rotation.rem_euclid(360), 90 | 270) { (h, w) } else { (w, h) }; + [at[0] - w / 2.0, at[1] - h / 2.0, at[0] + w / 2.0, at[1] + h / 2.0] + } +} + +/// The stamps of Acrobat's stamp palette (drawn in PdfCraft's own style). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum StampKind { + // Standard business. + Approved, + Completed, + Confidential, + Draft, + Final, + ForComment, + ForPublicRelease, + InformationOnly, + NotApproved, + NotForPublicRelease, + PreliminaryResults, + Void, + // Sign here. + Accepted, + InitialHere, + Rejected, + SignHere, + Witness, + // Dynamic (with a "By … at …" line). + DynApproved, + DynConfidential, + DynReceived, + DynReviewed, + DynRevised, +} + +/// The palette's sections. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum StampGroup { + Dynamic, + SignHere, + StandardBusiness, +} + +impl StampKind { + pub const ALL: [StampKind; 22] = [ + StampKind::DynApproved, + StampKind::DynConfidential, + StampKind::DynReceived, + StampKind::DynReviewed, + StampKind::DynRevised, + StampKind::Accepted, + StampKind::InitialHere, + StampKind::Rejected, + StampKind::SignHere, + StampKind::Witness, + StampKind::Approved, + StampKind::Completed, + StampKind::Confidential, + StampKind::Draft, + StampKind::Final, + StampKind::ForComment, + StampKind::ForPublicRelease, + StampKind::InformationOnly, + StampKind::NotApproved, + StampKind::NotForPublicRelease, + StampKind::PreliminaryResults, + StampKind::Void, + ]; + + pub fn group(self) -> StampGroup { + use StampKind::*; + match self { + DynApproved | DynConfidential | DynReceived | DynReviewed | DynRevised => StampGroup::Dynamic, + Accepted | InitialHere | Rejected | SignHere | Witness => StampGroup::SignHere, + _ => StampGroup::StandardBusiness, + } + } + + /// The text on the stamp. + pub fn label(self) -> &'static str { + use StampKind::*; + match self { + Approved | DynApproved => "APPROVED", + Completed => "COMPLETED", + Confidential | DynConfidential => "CONFIDENTIAL", + Draft => "DRAFT", + Final => "FINAL", + ForComment => "FOR COMMENT", + ForPublicRelease => "FOR PUBLIC RELEASE", + InformationOnly => "INFORMATION ONLY", + NotApproved => "NOT APPROVED", + NotForPublicRelease => "NOT FOR PUBLIC RELEASE", + PreliminaryResults => "PRELIMINARY RESULTS", + Void => "VOID", + Accepted => "ACCEPTED", + InitialHere => "INITIAL HERE", + Rejected => "REJECTED", + SignHere => "SIGN HERE", + Witness => "WITNESS", + DynReceived => "RECEIVED", + DynReviewed => "REVIEWED", + DynRevised => "REVISED", + } + } + + /// `/Name`: the standard stamp names of ISO 32000-2 Table 184 where one exists. + pub fn name(self) -> &'static str { + use StampKind::*; + match self { + Approved => "Approved", + Confidential => "Confidential", + Draft => "Draft", + Final => "Final", + ForComment => "ForComment", + ForPublicRelease => "ForPublicRelease", + NotApproved => "NotApproved", + NotForPublicRelease => "NotForPublicRelease", + Completed => "PCCompleted", + InformationOnly => "PCInformationOnly", + PreliminaryResults => "PCPreliminaryResults", + Void => "PCVoid", + Accepted => "PCAccepted", + InitialHere => "PCInitialHere", + Rejected => "PCRejected", + SignHere => "PCSignHere", + Witness => "PCWitness", + DynApproved => "PCDynApproved", + DynConfidential => "PCDynConfidential", + DynReceived => "PCDynReceived", + DynReviewed => "PCDynReviewed", + DynRevised => "PCDynRevised", + } + } + + pub fn from_name(n: &[u8]) -> Option { + Self::ALL.into_iter().find(|k| k.name().as_bytes() == n) + } + + /// The stamp's colour: green for approval, red for refusal and restriction, blue otherwise. + pub fn color(self) -> Rgb { + use StampKind::*; + match self { + Approved | Completed | Final | Accepted | DynApproved | DynReceived | DynReviewed => [0.13, 0.55, 0.13], + NotApproved | Rejected | Void | Confidential | NotForPublicRelease | DynConfidential => [0.80, 0.10, 0.10], + SignHere | InitialHere | Witness => [0.85, 0.35, 0.05], + _ => [0.10, 0.30, 0.70], + } + } + + /// The stamp's size (points) for its label, as placed with a click. + pub fn size(self) -> (f64, f64) { + let w = appearance::text_width(self.label(), 16.0) * 1.12 + 24.0; + let h = if self.group() == StampGroup::Dynamic { 42.0 } else { 30.0 }; + let w = if self.group() == StampGroup::SignHere { w + 14.0 } else { w }; + (w.max(80.0), h) + } +} + +/// A line ending (`/LE`): the ten styles in ISO 32000-2 Table 217. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LineEnding { + None, + Square, + Circle, + Diamond, + OpenArrow, + ClosedArrow, + Butt, + ROpenArrow, + RClosedArrow, + Slash, +} + +impl LineEnding { + pub const ALL: [Self; 10] = [ + Self::None, + Self::Square, + Self::Circle, + Self::Diamond, + Self::OpenArrow, + Self::ClosedArrow, + Self::Butt, + Self::ROpenArrow, + Self::RClosedArrow, + Self::Slash, + ]; + + pub fn name(self) -> &'static str { + match self { + Self::None => "None", + Self::Square => "Square", + Self::Circle => "Circle", + Self::Diamond => "Diamond", + Self::OpenArrow => "OpenArrow", + Self::ClosedArrow => "ClosedArrow", + Self::Butt => "Butt", + Self::ROpenArrow => "ROpenArrow", + Self::RClosedArrow => "RClosedArrow", + Self::Slash => "Slash", + } + } + + pub fn parse(name: &str) -> Option { + Self::ALL.into_iter().find(|ending| ending.name() == name) + } +} + +/// Geometry of a new comment, in PDF user space of its page. +#[derive(Clone, Debug, PartialEq)] +pub enum Shape { + /// A sticky note whose icon's top-left corner is at `at`. + Note { + at: [f64; 2], + icon: NoteIcon, + }, + /// Text markup over quadrilaterals: `[x1 y1 x2 y2 x3 y3 x4 y4]` = top-left, top-right, + /// bottom-left, bottom-right (the order Acrobat writes, §12.5.6.10). + TextMarkup { + kind: Markup, + quads: Vec<[f64; 8]>, + }, + Rectangle { + rect: [f64; 4], + }, + Oval { + rect: [f64; 4], + }, + /// A line. An open arrow at `to` is `start: None`, `end: OpenArrow` (the Arrow tool). + Line { + from: [f64; 2], + to: [f64; 2], + start: LineEnding, + end: LineEnding, + }, + /// Translucent freehand highlighting, stored as ink with a distinct intent. + FreehandHighlight { + strokes: Vec>, + }, + /// Freehand strokes (Draw tool). + Ink { + strokes: Vec>, + }, + /// A text box (FreeText) showing the comment's contents. + TextBox { + rect: [f64; 4], + font_size: f64, + }, + /// Fill & Sign text typed onto the page (FreeText, typewriter intent, no border). + Typewriter { + rect: [f64; 4], + font_size: f64, + }, + /// A Fill & Sign mark in `rect`. + Mark { + rect: [f64; 4], + mark: FillMark, + }, + /// A drawn signature (ink strokes). + Signature { + strokes: Vec>, + }, + /// A rubber stamp from the stamp palette; `by` is the dynamic stamps' second line. + Stamp { + rect: [f64; 4], + stamp: StampKind, + by: Option, + }, + /// A typed signature or initials: filled outlines (each contour as points normalised to + /// `rect`, 0–1, y up), drawn with the non-zero winding rule. + TypedSignature { + rect: [f64; 4], + contours: Vec>, + }, + /// A custom stamp: a picture already in the document (an image XObject, or a form XObject + /// whose `/Matrix` maps it to `size` points) filling `rect`, named `name`. + CustomStamp { + rect: [f64; 4], + name: String, + picture: ObjRef, + image: bool, + size: (f64, f64), + }, + /// A redaction mark (§12.5.6.23) over quadrilaterals (text) or one rectangle as a quad + /// (areas, pages). `overlay` is the text shown on the box once applied, drawn with `look`. + Redact { + quads: Vec<[f64; 8]>, + overlay: String, + look: OverlayLook, + }, + /// A closed polygon (Polygon tool); with `cloud`, Acrobat's Cloud tool: the same polygon + /// with a cloudy border (`/BE /S /C`, `/IT /PolygonCloud`). + Polygon { + vertices: Vec<[f64; 2]>, + cloud: bool, + }, + /// Connected lines (Polygonal Line tool). `start` is the first vertex, `end` the last. + PolyLine { + vertices: Vec<[f64; 2]>, + start: LineEnding, + end: LineEnding, + }, + /// A text callout: a text box at `rect` with a leader line from `point` (arrowhead) via + /// `knee` to the box (FreeText, `/IT /FreeTextCallout`, `/CL`). + Callout { + rect: [f64; 4], + knee: [f64; 2], + point: [f64; 2], + font_size: f64, + ending: LineEnding, + }, + /// Insert text: a caret in `rect` (its point at the top centre). + Caret { + rect: [f64; 4], + }, + /// Attach a file as a comment: an icon whose top-left corner is at `at`, holding `data` + /// (embedded) under the name `file`. + Attachment { + at: [f64; 2], + icon: AttachIcon, + file: String, + data: Vec, + }, +} + +/// The standard file attachment icons (§12.5.6.15). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum AttachIcon { + #[default] + PushPin, + Paperclip, + Graph, + Tag, +} + +impl AttachIcon { + pub const ALL: [AttachIcon; 4] = [AttachIcon::PushPin, AttachIcon::Paperclip, AttachIcon::Graph, AttachIcon::Tag]; + pub fn name(self) -> &'static str { + match self { + AttachIcon::PushPin => "PushPin", + AttachIcon::Paperclip => "Paperclip", + AttachIcon::Graph => "Graph", + AttachIcon::Tag => "Tag", + } + } + pub fn from_name(n: &str) -> Option { + Self::ALL.into_iter().find(|i| i.name().eq_ignore_ascii_case(n)) + } +} + +/// A rectangle `[x0 y0 x1 y1]` as a quad in Acrobat's order (top-left, top-right, bottom-left, +/// bottom-right). +pub fn rect_quad(r: [f64; 4]) -> [f64; 8] { + let [x0, y0, x1, y1] = normalize(r); + [x0, y1, x1, y1, x0, y0, x1, y0] +} + +impl Shape { + /// Return the PDF `/Subtype` name for this annotation shape. + pub fn subtype(&self) -> &'static str { + match self { + Shape::Note { .. } => "Text", + Shape::TextMarkup { kind, .. } => kind.subtype(), + Shape::Rectangle { .. } => "Square", + Shape::Oval { .. } => "Circle", + Shape::Line { .. } => "Line", + Shape::Ink { .. } | Shape::FreehandHighlight { .. } | Shape::Signature { .. } => "Ink", + Shape::TextBox { .. } | Shape::Typewriter { .. } | Shape::Callout { .. } => "FreeText", + Shape::Mark { .. } | Shape::Stamp { .. } | Shape::CustomStamp { .. } | Shape::TypedSignature { .. } => "Stamp", + Shape::Redact { .. } => "Redact", + Shape::Polygon { .. } => "Polygon", + Shape::PolyLine { .. } => "PolyLine", + Shape::Caret { .. } => "Caret", + Shape::Attachment { .. } => "FileAttachment", + } + } +} + +/// The standard fonts of redaction overlay text. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum OverlayFont { + #[default] + Helvetica, + Times, + Courier, +} + +impl OverlayFont { + pub const ALL: [OverlayFont; 3] = [OverlayFont::Helvetica, OverlayFont::Times, OverlayFont::Courier]; + + pub fn name(self) -> &'static str { + match self { + OverlayFont::Helvetica => "Helvetica", + OverlayFont::Times => "Times Roman", + OverlayFont::Courier => "Courier", + } + } + + /// The `/DA` font resource name (Acrobat's form font names). + pub fn resource(self) -> &'static str { + match self { + OverlayFont::Helvetica => "Helv", + OverlayFont::Times => "TiRo", + OverlayFont::Courier => "Cour", + } + } + + pub fn from_resource(n: &str) -> OverlayFont { + match n { + "TiRo" | "Times-Roman" | "TimesRoman" => OverlayFont::Times, + "Cour" | "Courier" => OverlayFont::Courier, + _ => OverlayFont::Helvetica, + } + } +} + +/// How redaction overlay text is drawn (Redaction Properties ▸ Appearance). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct OverlayLook { + pub font: OverlayFont, + /// Points; 0 = auto-size to fit the area. + pub size: f64, + pub color: Rgb, + /// 0 left, 1 centre, 2 right. + pub align: u8, + /// Repeat the text to fill the area. + pub repeat: bool, +} + +impl Default for OverlayLook { + fn default() -> Self { + Self { font: OverlayFont::Helvetica, size: 0.0, color: [1.0, 0.0, 0.0], align: 1, repeat: false } + } +} + +/// How a comment looks. +#[derive(Clone, Debug, PartialEq)] +pub struct Style { + /// Stroke colour (text colour for text boxes, icon colour for notes). + pub color: Rgb, + /// 0–1 (`/CA`). + pub opacity: f64, + /// Border / stroke width in points (0 = none). + pub width: f64, + /// Interior fill for rectangles and ovals (`/IC`), background for text boxes. + pub fill: Option, +} + +/// The style properties changed by one annotation edit. `None` leaves a property unchanged. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct StyleChange { + pub color: Option, + pub opacity: Option, + pub width: Option, + pub endings: Option>, +} + +impl StyleChange { + pub fn new(color: Option, opacity: Option, width: Option, endings: Option>) -> Self { + Self { color, opacity, width, endings } + } +} + +/// Inputs for replacing selected text with a grouped strikeout and caret annotation. +pub struct TextReplacement<'a> { + pub page: usize, + pub quads: &'a [[f64; 8]], + pub replacement: &'a str, + pub author: &'a str, + pub strike: &'a Style, + pub caret: &'a Style, + pub strike_meta: &'a Meta, + pub caret_meta: &'a Meta, +} + +impl Default for Style { + fn default() -> Self { + Self { color: [1.0, 0.82, 0.0], opacity: 1.0, width: 1.0, fill: None } + } +} + +impl Style { + /// The default look of each commenting tool. + pub fn default_for(shape: &Shape) -> Self { + let (color, width) = match shape { + Shape::Note { .. } => ([1.0, 0.82, 0.0], 1.0), + Shape::TextMarkup { kind: Markup::Highlight, .. } => ([1.0, 0.94, 0.0], 1.0), + Shape::TextMarkup { kind: Markup::Underline, .. } => ([0.0, 0.47, 0.84], 1.0), + Shape::TextMarkup { kind: Markup::StrikeOut, .. } => ([0.89, 0.13, 0.13], 1.0), + Shape::TextMarkup { kind: Markup::Squiggly, .. } => ([0.18, 0.62, 0.36], 1.0), + Shape::Rectangle { .. } | Shape::Oval { .. } | Shape::Line { .. } | Shape::Polygon { .. } | Shape::PolyLine { .. } => { + ([0.89, 0.13, 0.13], 2.0) + } + Shape::Callout { .. } => ([0.0, 0.0, 0.0], 1.0), + Shape::Caret { .. } | Shape::Attachment { .. } => ([0.0, 0.47, 0.84], 1.0), + Shape::FreehandHighlight { .. } => return Self { color: [1.0, 0.94, 0.0], opacity: 0.35, width: 14.0, fill: None }, + Shape::Ink { .. } => ([0.0, 0.4, 0.87], 2.0), + Shape::TextBox { .. } | Shape::Typewriter { .. } => ([0.0, 0.0, 0.0], 0.0), + Shape::Mark { .. } => ([0.0, 0.0, 0.0], 1.5), + Shape::Signature { .. } => ([0.0, 0.0, 0.0], 1.5), + Shape::Stamp { stamp, .. } => (stamp.color(), 2.0), + Shape::CustomStamp { .. } => ([0.0, 0.0, 0.0], 0.0), + Shape::TypedSignature { .. } => ([0.0, 0.0, 0.0], 0.0), + // Red outline while marked; a black box once applied. + Shape::Redact { .. } => return Self { color: [0.89, 0.13, 0.13], opacity: 1.0, width: 1.0, fill: Some([0.0, 0.0, 0.0]) }, + }; + Self { color, opacity: 1.0, width, fill: None } + } +} + +/// A comment to add. +#[derive(Clone, Debug, PartialEq)] +pub struct NewAnnotation { + /// 0-based page index. + pub page: usize, + pub shape: Shape, + pub style: Style, + /// The comment text (`/Contents`); the text shown by a text box. + pub contents: String, + /// `/T` (shown as the comment's author). + pub author: String, +} + +/// Values stamped onto what an edit creates or changes, supplied by the caller so edits stay +/// deterministic: a PDF date (`D:…`) and a unique id for `/NM`. +#[derive(Clone, Debug, PartialEq, Default)] +pub struct Meta { + pub date: Option, + pub id: String, +} + +/// Review states a reply can set (§12.5.6.3, Table 172; Acrobat's "Set status"). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ReviewState { + None, + Accepted, + Rejected, + Cancelled, + Completed, +} + +impl ReviewState { + pub fn name(self) -> &'static str { + match self { + ReviewState::None => "None", + ReviewState::Accepted => "Accepted", + ReviewState::Rejected => "Rejected", + ReviewState::Cancelled => "Cancelled", + ReviewState::Completed => "Completed", + } + } + + pub fn from_name(n: &str) -> Option { + [Self::None, Self::Accepted, Self::Rejected, Self::Cancelled, Self::Completed].into_iter().find(|s| s.name().eq_ignore_ascii_case(n)) + } +} + +// ── building ──────────────────────────────────────────────────────────────────────────────── + +/// Annotation flags (§12.5.3). +pub(crate) const FLAG_PRINT: i64 = 4; + +pub(crate) const FLAG_NO_ZOOM: i64 = 8; + +pub(crate) const FLAG_NO_ROTATE: i64 = 16; + +pub(crate) const FLAG_HIDDEN: i64 = 2; + +pub(crate) const FLAG_NO_VIEW: i64 = 32; + +pub(crate) const FLAG_LOCKED: i64 = 128; + +/// Size of a note icon (points, unscaled by zoom). +pub const NOTE_SIZE: f64 = 20.0; diff --git a/crates/audit/src/cover.rs b/crates/audit/src/cover.rs index 041ba9ddd8..bb5adf7ff8 100644 --- a/crates/audit/src/cover.rs +++ b/crates/audit/src/cover.rs @@ -15,7 +15,7 @@ //! the joined stream)`; rectangles inside a form XObject carry the `Do` operator's position. //! Positions compare lexicographically in paint order. -use pdfcraft_content::{Matrix, Op, Pieces, parse}; +use pdfcraft_content::{Matrix, Op, Pieces, ops}; use pdfcraft_cos::{DecodeBudget, Dict, Document, Object}; use crate::AuditError; @@ -123,12 +123,13 @@ impl<'a> Walker<'a> { Ok(()) } - /// Walk one operator slice with the given graphics state. `at_do` is the `Do` position to + /// Walk one stream's operators, read one at a time (a stream of millions of short operators + /// is never held whole), with the given graphics state. `at_do` is the `Do` position to /// stamp on rectangles found inside a form XObject, or `None` at page level (where each /// fill operator's own position is used). fn walk_ops( &mut self, - ops: &[Op], + ops: impl Iterator, gs: &mut Gs, stack: &mut Vec, resources: &Dict, @@ -136,8 +137,9 @@ impl<'a> Walker<'a> { at_do: Option<(usize, usize)>, ) -> Result<(), AuditError> { let mut path = Path::Empty; - for (i, op) in ops.iter().enumerate() { + for (i, op) in ops.enumerate() { self.bump()?; + let op = &op; // Paint position of this operator: its own `(piece, joined index)` at page level, // the enclosing `Do`'s position inside a form XObject. let pos = match at_do { @@ -257,8 +259,9 @@ impl<'a> Walker<'a> { /// changes inside never leak out; every rectangle found is stamped with the `Do` /// position `pos`. fn walk_xobject(&mut self, name: &[u8], gs: &Gs, resources: &Dict, depth: usize, pos: (usize, usize)) -> Result<(), AuditError> { - let xobjects = resources.get(b"XObject").map(|o| self.doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default(); - let Some(entry) = xobjects.get(name) else { return Ok(()) }; + // Borrowed, not copied: a `Do` must not cost the size of the whole XObject table. + let xobjects = resources.get(b"XObject").map(|o| self.doc.resolve(o)); + let Some(entry) = xobjects.as_deref().and_then(Object::as_dict).and_then(|d| d.get(name)) else { return Ok(()) }; let resolved = self.doc.resolve(entry); let Object::Stream(stream) = &*resolved else { return Ok(()) }; let dict = &stream.dict; @@ -274,18 +277,18 @@ impl<'a> Walker<'a> { (v.len() == 6 && v.iter().all(|x| x.is_finite())).then(|| Matrix([v[0], v[1], v[2], v[3], v[4], v[5]])) }) .unwrap_or(Matrix::IDENTITY); - let sub_resources = dict.get(b"Resources").map(|o| self.doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default(); + let (sub_resources, empty) = (dict.get(b"Resources").map(|o| self.doc.resolve(o)), Dict::new()); + let sub_resources = sub_resources.as_deref().and_then(Object::as_dict).unwrap_or(&empty); if !self.budget.enter_form() { return Err(AuditError::TooManyForms); } // A form stream that cannot be decoded fails the audit: it might hide a cover. let data = self.budget.decode_strict(stream)?; - let ops = parse(&data).ops; // The XObject matrix maps form space into the user space active at `Do`. let mut sub_gs = gs.clone(); sub_gs.ctm = matrix.then(&gs.ctm); let mut sub_stack = Vec::new(); - self.walk_ops(&ops, &mut sub_gs, &mut sub_stack, &sub_resources, depth + 1, Some(pos)) + self.walk_ops(ops(&data), &mut sub_gs, &mut sub_stack, sub_resources, depth + 1, Some(pos)) } } @@ -298,13 +301,10 @@ fn user_point(gs: &Gs, op: &Op) -> Option<[f64; 2]> { /// The non-stroking alpha (`/ca`) of a named ExtGState, defaulting to opaque. fn ext_alpha(doc: &Document, resources: &Dict, name: &[u8]) -> f64 { - let alpha = resources - .get(b"ExtGState") - .map(|o| doc.resolve(o)) - .and_then(|o| o.as_dict().cloned()) - .and_then(|d| d.get(name).map(|o| doc.resolve(o))) - .and_then(|o| o.as_dict().cloned()) - .and_then(|d| d.get(b"ca").map(|o| doc.resolve(o)).and_then(|o| o.as_f64())); + // Borrowed, not copied: a `gs` must not cost the size of the whole ExtGState table. + let states = resources.get(b"ExtGState").map(|o| doc.resolve(o)); + let state = states.as_deref().and_then(Object::as_dict).and_then(|d| d.get(name)).map(|o| doc.resolve(o)); + let alpha = state.as_deref().and_then(Object::as_dict).and_then(|d| d.get(b"ca").map(|o| doc.resolve(o)).and_then(|o| o.as_f64())); alpha.filter(|a| a.is_finite()).map(|a| a.clamp(0.0, 1.0)).unwrap_or(1.0) } @@ -330,10 +330,11 @@ pub fn filled_rects(doc: &Document, page: &Dict) -> Result, Audi } } let pieces = Pieces::join(&streams); - let parsed = pieces.parse(); let mut w = Walker { doc, pieces: &pieces, out: Vec::new(), ops_seen: 0, budget }; let mut gs = Gs::default(); let mut stack = Vec::new(); - w.walk_ops(&parsed.ops, &mut gs, &mut stack, &resources, 0, None)?; + // Read one operator at a time: the joined stream can hold millions of them within the + // decode budget, and the walk only ever needs the current one. + w.walk_ops(ops(pieces.data()), &mut gs, &mut stack, &resources, 0, None)?; Ok(w.out) } diff --git a/crates/audit/src/lib.rs b/crates/audit/src/lib.rs index f2bcf63358..97429d4c40 100644 --- a/crates/audit/src/lib.rs +++ b/crates/audit/src/lib.rs @@ -99,6 +99,9 @@ const COVER_FRACTION: f64 = 0.4; const WORD_FRACTION: f64 = 0.5; /// WCAG contrast ratio below which the text is unreadable against the cover. const COVER_CONTRAST: f64 = 3.0; +/// Bytes of a hidden line's text read (copied or trimmed) that cost one comparison: a byte scan +/// is far cheaper than a box comparison, but a long padded line under many covers adds up. +const TEXT_BYTES_PER_COMPARISON: usize = 16; /// Audit every page for faux redactions and unapplied redaction marks. Fails closed: any /// content that cannot be decoded, or that needs more work or report than the audit's bounds, is @@ -157,15 +160,24 @@ impl Budget { cover: &[f64; 4], hides: impl Fn(&TextLine) -> bool, ) -> Result, AuditError> { - self.comparisons = self.comparisons.saturating_add(self.page_cost); - if self.comparisons > self.limits.comparisons { - return Err(AuditError::TooManyComparisons); - } + self.spend(self.page_cost)?; if !copying { - return Ok(lines.iter().any(|line| hides(line) && !line.text.trim().is_empty()).then(String::new)); + let mut hidden = false; + for line in lines.iter().filter(|l| hides(l)) { + // Trimming walks the line's text (padding and all): it costs its bytes. + self.spend(line.text.len() / TEXT_BYTES_PER_COMPARISON)?; + if !line.text.trim().is_empty() { + hidden = true; + break; + } + } + return Ok(hidden.then(String::new)); } let mut covered = String::new(); for line in lines.iter().filter(|l| hides(l)) { + // Copying and trimming the line walks its text once per cover: a long padded + // line under many covers would otherwise be read far more than its runs suggest. + self.spend(line.text.len() / TEXT_BYTES_PER_COMPARISON)?; if !covered.is_empty() { covered.push(' '); } @@ -174,11 +186,21 @@ impl Budget { Ok((!covered.trim().is_empty()).then_some(covered)) } + /// `n` units of work done against the comparison bound. + fn spend(&mut self, n: usize) -> Result<(), AuditError> { + self.comparisons = self.comparisons.saturating_add(n); + if self.comparisons > self.limits.comparisons { + return Err(AuditError::TooManyComparisons); + } + Ok(()) + } + /// The text a finding reports for `covered`: none when copying is withheld. Charges what it - /// reports against the output bound. + /// reports, and the finding's own record, against the output bound: a finding with no text + /// (copying withheld, or a shared annotation list on many pages) still takes memory. fn report_text(&mut self, covered: String, copying: bool) -> Result { let reported = if copying { covered } else { String::new() }; - self.covered_text = self.covered_text.saturating_add(reported.len()); + self.covered_text = self.covered_text.saturating_add(reported.len()).saturating_add(std::mem::size_of::()); if self.covered_text > self.limits.covered_text { return Err(AuditError::TooMuchOutput); } @@ -321,17 +343,14 @@ enum AnnotationCover { fn annots(doc: &Document, page: &Dict) -> Result, AuditError> { let Some(a) = page.get(b"Annots") else { return Ok(Vec::new()) }; let resolved = doc.resolve(a); - let items: Vec = match &*resolved { - pdfcraft_cos::Object::Array(items) => items.clone(), - // A missing /Annots means no marks; a malformed one fails the audit. - _ => return Err(AuditError::BadAnnots), - }; + // A missing /Annots means no marks; a malformed one fails the audit. + let Some(items) = resolved.as_array() else { return Err(AuditError::BadAnnots) }; let mut out = Vec::new(); - for a in &items { - let dict = match &*doc.resolve(a) { - pdfcraft_cos::Object::Dict(d) => d.clone(), - _ => continue, - }; + for a in items { + // Borrowed from the resolved object: one large dictionary listed many times is not + // copied once per listing. + let resolved = doc.resolve(a); + let Some(dict) = resolved.as_dict() else { continue }; let v: Vec = match dict.get(b"Rect") { None => continue, Some(r) => doc.resolve(r).as_array().map(|arr| arr.iter().filter_map(|o| doc.resolve(o).as_f64()).collect()).unwrap_or_default(), @@ -340,7 +359,7 @@ fn annots(doc: &Document, page: &Dict) -> Result, AuditErro continue; } let rect = [v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3])]; - match subtype(doc, &dict)?.as_deref() { + match subtype(doc, dict)?.as_deref() { Some(b"Redact") => out.push(AnnotationCover::Redact(rect)), Some(b"Square") => { let flags = dict.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0); @@ -349,7 +368,7 @@ fn annots(doc: &Document, page: &Dict) -> Result, AuditErro } let alpha = dict.get(b"CA").map(|o| doc.resolve(o)).and_then(|o| o.as_f64()).unwrap_or(1.0); if alpha >= 1.0 - && let Some(rgb) = annotation_color(doc, &dict, b"IC") + && let Some(rgb) = annotation_color(doc, dict, b"IC") { out.push(AnnotationCover::Square { rect, rgb }); } diff --git a/crates/audit/src/tests.rs b/crates/audit/src/tests.rs index e5fb9ab683..074eb39f66 100644 --- a/crates/audit/src/tests.rs +++ b/crates/audit/src/tests.rs @@ -720,9 +720,11 @@ fn runs_without_words_count_against_the_comparison_bound() { let doc = open(one_page(&content, "")); let lines = pdfcraft_edit::glyph_text_lines(&doc, &pdfcraft_model::pages(&doc)[0]).expect("text"); assert_eq!(lines.iter().map(|l| (l.ink.len(), l.runs().count())).collect::>(), [(1, 51)]); - let limits = crate::Limits { comparisons: 50, covered_text: usize::MAX }; + // Plus the hidden line's text, read once to copy it. + let text = lines[0].text.len() / crate::TEXT_BYTES_PER_COMPARISON; + let limits = crate::Limits { comparisons: 50 + text, covered_text: usize::MAX }; assert!(matches!(crate::audit(&doc, limits), Err(crate::AuditError::TooManyComparisons))); - let limits = crate::Limits { comparisons: 51, covered_text: usize::MAX }; + let limits = crate::Limits { comparisons: 51 + text, covered_text: usize::MAX }; assert_eq!(crate::audit(&doc, limits).expect("within the budget").len(), 1); } @@ -746,14 +748,18 @@ fn copy_restricted_documents_withhold_covered_text() { let findings = audit_redactions(&user).expect("audit must run"); assert_eq!(findings.len(), 1, "the cover is still reported: {findings:?}"); assert_eq!(findings[0].covered_text, ""); - // The output bound counts the text reported, so withheld text costs nothing. - let nothing = crate::Limits { comparisons: usize::MAX, covered_text: 0 }; + // The output bound counts the text reported and the finding's record, so withheld text + // costs only the record. + let record = std::mem::size_of::(); + let nothing = crate::Limits { comparisons: usize::MAX, covered_text: record }; assert_eq!(crate::audit(&user, nothing).expect("withheld text is not output").len(), 1); + let none = crate::Limits { covered_text: record - 1, ..nothing }; + assert!(matches!(crate::audit(&user, none), Err(crate::AuditError::TooMuchOutput))); let owner = Document::open_with_password(bytes, Some("owner")).expect("open"); let findings = audit_redactions(&owner).expect("audit must run"); assert_eq!(findings.len(), 1, "{findings:?}"); assert_eq!(findings[0].covered_text, "SECRET DATA 123"); - let exact = crate::Limits { comparisons: usize::MAX, covered_text: "SECRET DATA 123".len() }; + let exact = crate::Limits { comparisons: usize::MAX, covered_text: "SECRET DATA 123".len() + record }; assert!(crate::audit(&owner, exact).is_ok()); let short = crate::Limits { covered_text: exact.covered_text - 1, ..exact }; assert!(matches!(crate::audit(&owner, short), Err(crate::AuditError::TooMuchOutput))); @@ -820,3 +826,52 @@ fn form_entries_past_the_page_allowance_fail_the_audit() { let page: Dict = [(b"Contents".to_vec(), Object::Ref(draws)), (b"Resources".to_vec(), Object::Dict(resources))].into_iter().collect(); assert!(matches!(crate::cover::filled_rects(&doc, &page), Err(crate::AuditError::TooManyForms))); } + +/// Copying a covered line's text walks all of it, padding included, so a long padded line under +/// many covers costs its bytes once per cover, not one run per cover (CodeRabbit). +#[test] +fn covered_text_bytes_count_against_the_comparison_bound() { + // One run: 20 KiB of spaces around a word, which trims to the word alone. + let mut content = b"BT /F1 24 Tf 100 700 Td (".to_vec(); + content.extend_from_slice(&[b' '; 10_000]); + content.extend_from_slice(b"SECRET"); + content.extend_from_slice(&[b' '; 10_000]); + content.extend_from_slice(b") Tj ET\n0 g\n"); + for _ in 0..10 { + content.extend_from_slice(b"-1 688 1000000 32 re f\n"); + } + let doc = open(one_page(&content, "")); + let lines = pdfcraft_edit::glyph_text_lines(&doc, &pdfcraft_model::pages(&doc)[0]).expect("text"); + assert_eq!(lines.len(), 1); + let bytes = lines[0].text.len(); + assert!(bytes > 20_000, "{bytes} bytes of text"); + // Each cover: one comparison for the line, plus its bytes when it is hidden. + let exact = crate::Limits { comparisons: 10 * (1 + bytes / crate::TEXT_BYTES_PER_COMPARISON), covered_text: usize::MAX }; + let findings = crate::audit(&doc, exact).expect("within the budget"); + assert_eq!(findings.len(), 10, "{findings:?}"); + assert!(findings.iter().all(|f| f.covered_text == "SECRET"), "{findings:?}"); + let short = crate::Limits { comparisons: exact.comparisons - 1, ..exact }; + assert!(matches!(crate::audit(&doc, short), Err(crate::AuditError::TooManyComparisons))); +} + +/// One annotation list shared by many pages makes a finding on each: every finding's record +/// counts against the output bound, text or no text (CodeRabbit). +#[test] +fn every_finding_record_counts_against_the_output_bound() { + let content = b"BT /F1 24 Tf 100 700 Td (SECRET) Tj ET\n"; + let mut b = Builder::new(); + b.add("<< /Type /Catalog /Pages 2 0 R >>"); + b.add("<< /Type /Pages /Kids [3 0 R 7 0 R 8 0 R] /Count 3 >>"); + b.add("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots 6 0 R >>"); + b.add_stream(content); + b.add(FONT); + b.add("[<< /Type /Annot /Subtype /Redact /Rect [95 685 200 725] >>]"); + b.add("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots 6 0 R >>"); + b.add("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots 6 0 R >>"); + let doc = open(b.build()); + let one = "SECRET".len() + std::mem::size_of::(); + let exact = crate::Limits { comparisons: usize::MAX, covered_text: 3 * one }; + assert_eq!(crate::audit(&doc, exact).expect("within the budget").len(), 3); + let short = crate::Limits { covered_text: 3 * one - 1, ..exact }; + assert!(matches!(crate::audit(&doc, short), Err(crate::AuditError::TooMuchOutput))); +} diff --git a/crates/automation/README.md b/crates/automation/README.md index cdaaa13e64..8ffa2d0b3e 100644 --- a/crates/automation/README.md +++ b/crates/automation/README.md @@ -5,6 +5,8 @@ Agent control for PdfCraft: a headless tool table over the engine, and an opt-in - **Layer:** L7 (architecture §13), but headless. It depends on `engine`, `render` and `organize`, never on a UI toolkit. - **Status:** over a hundred tools; `pdfcraft-cli tools` lists the current set with their schemas (the count isn't repeated here, so it can't go stale). They cover inspection, rendering, text, page edits and organizing, page labels, bookmarks, comments and their review, form filling and authoring (fields, properties, scripts, data exchange, detection), redaction and sanitizing, password protection and signatures, calibrated 2D measurements and CSV export, adding text, images, links and stamps, optimizing, OCR, printing, export, metadata, undo/redo, save, combine, extract and split. Comment tools take geometry in the same top-left-origin points as everything else, and `comment_add` can mark text by searching for it (`find`). The MCP server also serves the open documents as resources, page images included. The running app's UI is driven separately, through the `ui.*` control channel in `pdfcraft-ui-egui`. +`fill_sign_add` text accepts line breaks, including blank lines. Its typewriter box grows to hold all wrapped rows, and saving/reopening preserves the contents and appearance. + ## API ```rust @@ -21,6 +23,9 @@ them with the image's current orientation. Full turns retain its appearance, rec and flips. Angles that are not multiples of 90 are refused without changing the document or its undo/redo history. +`page_add_text`, `content_update` and `text_edit` take per-line `aligns`. An `"inherit"` entry +follows the box-level `align`; legacy `null` entries remain accepted with the same meaning. + `Content` is `Json(Value)` or `Png { data, width, height }`. Errors are `UnknownTool`, `InvalidArgs` (the call didn't match the schema) or `Failed` (a readable message for the agent). ## Conventions @@ -112,13 +117,13 @@ The release packages ship `pdfcraft-cli` alongside the desktop app, so no build | Install | CLI | |---|---| -| Windows (MSI) | `C:\Program Files\PdfCraft\pdfcraft-cli.exe` by default (wherever you installed it otherwise), not on `PATH` | +| Windows (MSI) | `%LOCALAPPDATA%\Programs\ArtCraft\PdfCraft\pdfcraft-cli.exe` by default (`C:\Program Files\ArtCraft\PdfCraft\pdfcraft-cli.exe` for an all-users install, under `Program Files (x86)` instead for the 32-bit build, or wherever you installed it otherwise), not on `PATH` | | Linux (deb, rpm) | `/usr/bin/pdfcraft-cli` | | macOS | the separate `pdfcraft-cli--macos-.zip` release asset (the `.app` holds only the desktop app) | ```sh -# Windows, default install folder -claude mcp add pdfcraft -- "C:\Program Files\PdfCraft\pdfcraft-cli.exe" mcp --root +# Windows (PowerShell), default install folder +claude mcp add pdfcraft -- "$env:LOCALAPPDATA\Programs\ArtCraft\PdfCraft\pdfcraft-cli.exe" mcp --root # Linux, or macOS with the CLI unzipped onto PATH claude mcp add pdfcraft -- pdfcraft-cli mcp --root ``` @@ -181,6 +186,12 @@ working document unchanged. `content_list` or `object_list`. It removes only that item in a read-only preview and is mutually exclusive with `hide_text_block`. +When the renderer reports a warning for the page, `page_render` and `render_preview` return a +JSON part after the PNG, `{ "page": 1, "warnings": [...] }`, with the sentences +`pdfcraft-cli render` prints: "content past the page's safety budget was skipped" and "a colour +that could not be computed was painted in a fallback colour". A page without warnings returns +the PNG alone. + ## Existing text and document fonts @@ -193,6 +204,19 @@ undoable and survive saving/reopening. `image_hit` shares the UI's curve and hol The bounds, limits and font requirements are in [existing-text editing](../../docs/editing-existing-text.md) and [image editing](../../docs/editing-images.md). +### Superscript and subscript normalization + +`text_script` changes characters to their Unicode superscript or subscript forms; it does not +change the font size or baseline. For characters that have such forms, uniformly plain text +toggles to the requested script, and uniformly scripted text toggles back to plain text. +Characters without a matching form stay unchanged and do not determine the selection's state. + +A selection mixing plain and target-script characters normalizes to the requested script. +For example, subscript changes `H₂2` to `H₂₂`, then to `H22` on the next call; superscript +changes `Q²2` to `Q²²`, then to `Q22`. A second toggle does not reconstruct the original +mixture. Each call is one undoable text edit: `edit_undo` restores the exact prior text, +including its original mixture. Save/reopen preserves the resulting normalized characters. + ## Opening local GoToR links `link_open {doc, page, index, password?}` follows a local PDF link using `link_list` @@ -214,6 +238,18 @@ unapplied with a warning. 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. +## Editing button export values + +Use `form_set_props` with `field`, `export_value` and optional `widget` +(1-based, default 1) to change a checkbox or radio button export value. +`form_fields` returns `export_values` in widget order. The selected/default +appearance states are preserved; the operation is undoable and survives saving. +Widgets sharing an appearance state keep one export value. Bulk edits are not +supported for this widget-specific property. Empty values, `Off`, conflicting +choices and values over 4096 UTF-8 bytes return an error. + +Example: `form_set_props doc=1 field=agree export_value=Accepted widget=1`. + ## Viewer navigation history A running PdfCraft instance launched with `--control FILE` exposes its per-tab history through @@ -229,3 +265,10 @@ 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. + +## Fill & Sign text appearance + +`fill_sign_add` accepts `font_size` (4–300 pt, default 10) and `color` +(`#RRGGBB`, default black) for `text` and `date`. The colour is saved in the +FreeText appearance and survives reopening. Other mark/signature types reject +`color`; their appearance controls are separate. diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs index 20243dcdae..1f0c9cb8a5 100644 --- a/crates/automation/src/a11y.rs +++ b/crates/automation/src/a11y.rs @@ -8,6 +8,10 @@ use std::collections::BTreeSet; use crate::{Args, Automation, Result, ToolError, child, failed, unused, write_atomic}; +mod documents; +mod form_tools; +mod ocr; + fn status_id(s: Status) -> &'static str { match s { Status::Passed => "passed", @@ -574,511 +578,6 @@ impl Automation { } } -impl Automation { - /// Recognition settings from the arguments: `dpi` (clamped to 72–600), `language` (refused - /// unless the models read it) and `skip_text_pages`, each left at its default when absent. - pub(crate) fn ocr_settings(&self, a: &Args) -> Result { - let mut settings = pdfcraft_engine::ocr::OcrSettings::default(); - if let Some(d) = a.opt_num("dpi")? { - settings.dpi = d.clamp(72.0, 600.0) as f32; - } - if let Some(l) = a.opt_str("language")? { - if !pdfcraft_engine::ocr::LANGUAGES.iter().any(|x| x.0 == l) { - return Err(ToolError::InvalidArgs(format!("unsupported language {l:?}"))); - } - settings.language = l.into(); - } - if let Some(s) = a.opt_bool("skip_text_pages")? { - settings.skip_text_pages = s; - } - Ok(settings) - } - - /// Recognizes text in each of `paths` and writes the result under the same file name in - /// `folder`, reporting per file its output, word count and skipped pages, or its error. A - /// path outside the root refuses the whole call before any work starts. - pub(crate) fn ocr_recognize_files(&mut self, a: &Args) -> Result { - let settings = self.ocr_settings(a)?; - let folder = self.resolve(a.str("folder")?, true)?; - // A path that leaves the root refuses the whole call before any work starts, like the - // other tools (the write check passes missing files, which stay per-file errors below). - let paths = a.strs("paths")?; - for p in &paths { - self.resolve_for(p, crate::file_access::Access::Read, true)?; - } - // Two inputs that would write the same output file overwrite each other while both report - // success, so the whole batch is refused here: before the folder is created, anything is - // read, or the OCR engines load. - let targets: Vec = paths.iter().map(|p| child(&folder, &input_file_name(p))).collect(); - let shared = shared_file_names(&targets); - if !shared.is_empty() { - return Err(ToolError::InvalidArgs(format!( - "more than one input would write to the same output file, so each would replace the one before it. Colliding file names: {}. Give each input a different file name, or run them in separate batches with different folders.", - shared.join(", ") - ))); - } - std::fs::create_dir_all(&folder).map_err(|e| failed(e.to_string()))?; - let ocr = pdfcraft_engine::ocr::engine().map_err(failed)?; - let mut out = Vec::new(); - for p in paths { - let name = input_file_name(p); - let result = - self.resolve(p, false).map_err(|e| e.to_string()).and_then(|src| crate::read_input_bytes(&src).map_err(|e| e.to_string())).and_then( - |bytes| pdfcraft_engine::ocr::recognize_file(&name, std::sync::Arc::new(bytes), None, settings.clone(), &ocr, |_, _| true), - ); - out.push(match result { - Ok(r) => { - let target = child(&folder, &name); - write_atomic(&target, &r.bytes)?; - let skipped: Vec = r.pages.iter().filter(|p| p.skipped.is_some()).map(|p| p.page + 1).collect(); - json!({ "path": p, "output": target.to_string_lossy(), "words": r.words(), "skipped_pages": skipped }) - } - Err(e) => json!({ "path": p, "error": e }), - }); - } - Ok(json!({ "files": out })) - } - - /// Recognizes text on the open document's `pages` (every page when absent) and adds it, - /// reporting the total word count and, per page, why it was skipped or what it found. - pub(crate) fn ocr_recognize(&mut self, a: &Args) -> Result { - let settings = self.ocr_settings(a)?; - let pages = if a.opt_ints("pages")?.is_some() { self.pages(a, "pages")? } else { Vec::new() }; - let doc = self.doc(a)?; - // Recognizing needs only permission to change the document; the words it found are - // returned only when copying is allowed too. - let (id, copy) = (doc.id, doc.allows_copying()); - let found = self.session.recognize_text(id, &pages, settings).map_err(failed)?; - let list: Vec = found - .iter() - .map(|p| match &p.skipped { - Some(why) => json!({ "page": p.page + 1, "skipped": why }), - None if copy => json!({ "page": p.page + 1, "words": p.words.len(), "already_on_page": p.already_on_page, "text": p.text() }), - None => json!({ "page": p.page + 1, "words": p.words.len(), "already_on_page": p.already_on_page }), - }) - .collect(); - Ok(json!({ "words": found.iter().map(|p| p.words.len()).sum::(), "pages": list })) - } - - /// Whether text recognition's models are installed, the directories searched for them (in - /// order), and the languages it reads, each as a code and a name. - pub(crate) fn ocr_status(&self) -> Result { - use pdfcraft_engine::ocr; - let dirs: Vec = ocr::Models::search_dirs().iter().map(|d| d.to_string_lossy().into_owned()).collect(); - let langs: Vec = ocr::LANGUAGES.iter().map(|(c, n)| json!({ "code": c, "name": n })).collect(); - Ok(json!({ "available": ocr::available(), "search_dirs": dirs, "languages": langs })) - } -} - -/// The file name an input is written under: its own file name, or `document.pdf` when it has none. -fn input_file_name(path: &str) -> String { - std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "document.pdf".into()) -} - -/// The output file names that two or more of `targets` share, each spelling listed once, in input -/// order. Names are compared without regard to case on Windows and macOS and exactly elsewhere; on -/// Windows a trailing dot or space is not part of the name (`scan.pdf.` is `scan.pdf`). Unicode -/// normalization forms (NFC and NFD spellings of one name) are not compared, so such inputs are -/// not caught. The folder is the same for every target, so only the names are compared. -fn shared_file_names(targets: &[std::path::PathBuf]) -> Vec { - let mut groups: std::collections::BTreeMap)> = std::collections::BTreeMap::new(); - for target in targets { - let name = target.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); - let key = if cfg!(windows) { - name.trim_end_matches(['.', ' ']).to_lowercase() - } else if cfg!(target_os = "macos") { - // Case only; NFC and NFD spellings of one name stay distinct here (a known limit). - name.to_lowercase() - } else { - name.clone() - }; - let (count, spellings) = groups.entry(key).or_insert_with(|| (0, Vec::new())); - *count += 1; - if !spellings.contains(&name) { - spellings.push(name); - } - } - groups.into_values().filter(|(count, _)| *count > 1).flat_map(|(_, spellings)| spellings).collect() -} - -/// A script's request to the viewer as the JSON that running a script reports; a side panel -/// goes by its fixed id. -fn request_json(r: &pdfcraft_engine::js::Request) -> Value { - use pdfcraft_engine::js::Request as R; - match r { - R::Reset(n) => json!({ "reset": n }), - R::Print => json!({ "print": true }), - R::GoToPage(p) => json!({ "page": p + 1 }), - R::LaunchUrl(u) => json!({ "url": u }), - R::Submit(u) => json!({ "submit": u }), - R::Focus(f) => json!({ "focus": f }), - R::Beep => json!({ "beep": true }), - R::SaveAs => json!({ "save_as": true }), - R::ShowPanel(p) => json!({ "panel": p.id() }), - } -} - -impl Automation { - pub(crate) fn js_run(&mut self, a: &Args) -> Result { - let id = self.doc(a)?.id; - let o = self.session.run_javascript(id, a.str("script")?, a.opt_str("field")?).map_err(failed)?; - let reqs: Vec = o.requests.iter().map(request_json).collect(); - Ok( - json!({ "alerts": o.alerts, "console": o.console, "requests": reqs, "error": o.error, "document_errors": o.doc_errors, "result": o.result }), - ) - } - - pub(crate) fn js_document_scripts(&self, a: &Args) -> Result { - let list: Vec = self.doc(a)?.document_scripts().into_iter().map(|(n, s)| json!({ "name": n, "script": s })).collect(); - Ok(json!({ "scripts": list })) - } - - pub(crate) fn js_set_document_script(&mut self, a: &Args) -> Result { - let edit = pdfcraft_engine::Edit::SetDocumentScript { name: a.str("name")?.into(), script: a.opt_str("script")?.map(str::to_string) }; - let id = self.doc(a)?.id; - self.session.apply(id, edit).map_err(failed)?; - self.js_document_scripts(a) - } - - pub(crate) fn form_set_script(&mut self, a: &Args) -> Result { - let edit = pdfcraft_engine::Edit::SetFieldScript { - name: a.str("field")?.into(), - event: a.str("event")?.into(), - script: a.opt_str("script")?.map(str::to_string), - }; - let id = self.doc(a)?.id; - self.session.apply(id, edit).map_err(failed)?; - let out = self.session.take_js_output(id); - Ok(json!({ "field": a.str("field")?, "event": a.str("event")?, "console": out.console, "errors": out.errors })) - } - - pub(crate) fn form_merge_data(&mut self, a: &Args) -> Result { - let mut files = Vec::new(); - for p in a.strs("paths")? { - let path = self.resolve(p, false)?; - files.push((p.to_string(), crate::read_input_bytes(&path).map_err(|e| failed(format!("{p}: {e}")))?)); - } - let csv = pdfcraft_engine::merge_data_files(&files).map_err(failed)?; - let target = self.resolve(a.str("path")?, true)?; - write_atomic(&target, csv.as_bytes())?; - let columns = csv.lines().next().map_or(0, |h| h.split(',').count()); - Ok(json!({ "path": target.to_string_lossy(), "rows": files.len(), "columns": columns })) - } - - pub(crate) fn doc_export_office(&self, a: &Args) -> Result { - let path = self.resolve(a.str("path")?, true)?; - let ext = path.extension().map(|e| e.to_string_lossy().into_owned()).unwrap_or_default(); - let format = pdfcraft_engine::compare::OfficeFormat::from_extension(&ext) - .ok_or_else(|| ToolError::InvalidArgs(format!("unsupported extension {ext:?} (docx, pptx, html or rtf)")))?; - let export = self.doc_to_copy(a)?.export_office_checked(format); - write_atomic(&path, &export.bytes)?; - // Text left out to keep a hostile page bounded is said, not silent (#3120). - let truncated: Vec = export.truncated_pages.iter().map(|p| p.saturating_add(1)).collect(); - let mut out = json!({ "path": path.to_string_lossy(), "bytes": export.bytes.len(), "format": format.extension(), - "truncated": !truncated.is_empty(), "truncated_pages": truncated }); - if !export.truncated_pages.is_empty() { - use pdfcraft_engine::compare::{MAX_FORM_DEPTH, MAX_FORM_VISITS}; - out["warnings"] = json!([format!( - "forms nested more than {MAX_FORM_DEPTH} deep, or past the first {MAX_FORM_VISITS} forms of a page (forms the page draws itself count too), were left out on the pages in truncated_pages: their text and images are missing from the export, although the viewer may still show them" - )]); - } - Ok(out) - } - - fn pdfa_level(&self, a: &Args) -> Result { - let l = a.opt_str("level")?.unwrap_or("2b"); - pdfcraft_engine::pdfa::Level::from_id(l).ok_or_else(|| ToolError::InvalidArgs(format!("unknown PDF/A level {l:?} (2b or 3b)"))) - } - - pub(crate) fn pdfa_verify(&self, a: &Args) -> Result { - let level = self.pdfa_level(a)?; - let doc = self.doc(a)?; - let issues: Vec = doc - .pdfa_verify(level) - .iter() - .map(|i| json!({ "clause": i.clause, "message": i.message, "page": i.page.map(|p| p + 1), "fixable": i.fixable })) - .collect(); - let d = doc.standards(); - Ok(json!({ - "level": level.label(), - "compliant": issues.is_empty(), - "issues": issues, - "declared": { "pdfa": d.pdfa.map(|(p, c)| format!("PDF/A-{p}{}", c.to_lowercase())), "pdfua": d.pdfua, "output_intents": d.output_intents }, - })) - } - - pub(crate) fn print_readiness(&self, a: &Args) -> Result { - let report = self.doc(a)?.print_readiness(); - let findings: Vec = - report.findings.iter().map(|f| json!({ "id": f.id, "severity": f.severity, "message": f.message, "page": f.page })).collect(); - let pages: Vec = report - .pages - .iter() - .map(|p| { - json!({ - "page": p.page, - "media_mm": p.media_mm, - "crop_mm": p.crop_mm, - "trim_mm": p.trim_mm, - "bleed_mm": p.bleed_mm, - "trim_set": p.trim_set, - "bleed_set": p.bleed_set, - "bleed_margin_mm": p.bleed_margin_mm, - }) - }) - .collect(); - let images: Vec = report - .images - .iter() - .map(|image| { - json!({ - "page": image.page, - "name": image.name, - "ppi": image.ppi, - "pixels": image.pixels, - "size_mm": image.size_mm, - }) - }) - .collect(); - let pdfa: Vec = report - .pdfa_issues - .iter() - .map(|issue| json!({ "clause": issue.clause, "message": issue.message, "page": issue.page.map(|p| p + 1) })) - .collect(); - Ok(json!({ - "text": report.text(), - "findings": findings, - "pages": pages, - "images": images, - "output_intents": report.output_intents, - "fonts_not_embedded": report.fonts_not_embedded, - "pdfa_issues": pdfa, - "resolution_unknown": report.resolution_unknown, - })) - } - - pub(crate) fn pdfa_convert(&mut self, a: &Args) -> Result { - let level = self.pdfa_level(a)?; - let id = self.doc(a)?.id; - self.session.apply(id, pdfcraft_engine::Edit::ConvertPdfA { level }).map_err(failed)?; - self.pdfa_verify(a) - } - - pub(crate) fn action_list(&self) -> Result { - use pdfcraft_engine::actions::{Step, builtin}; - let step_json = |s: &Step| json!({ "step": s.id(), "arg": s.arg() }); - let actions: Vec = builtin() - .iter() - .map(|a| json!({ "name": a.name, "description": a.description, "steps": a.steps.iter().map(step_json).collect::>() })) - .collect(); - let steps: Vec = Step::all().iter().map(|s| json!({ "step": s.id(), "label": s.label(), "takes_arg": s.arg().is_some() })).collect(); - Ok(json!({ "actions": actions, "steps": steps })) - } - - pub(crate) fn action_run(&mut self, a: &Args) -> Result { - use pdfcraft_engine::actions::{Action, Step, builtin, run_on}; - let action = if let Some(name) = a.opt_str("action")? { - builtin() - .into_iter() - .find(|x| x.name.eq_ignore_ascii_case(name)) - .ok_or_else(|| ToolError::InvalidArgs(format!("no built-in action {name:?}")))? - } else { - let items = a.get("steps").and_then(Value::as_array).ok_or_else(|| ToolError::InvalidArgs("give action or steps".into()))?; - let steps = items - .iter() - .map(|it| { - let id = it["step"].as_str().unwrap_or(""); - Step::from_id(id, it["arg"].as_str().unwrap_or("")).ok_or_else(|| ToolError::InvalidArgs(format!("unknown step {id:?}"))) - }) - .collect::>>()?; - Action { name: "Custom".into(), description: String::new(), steps, builtin: false } - }; - let folder = self.resolve(a.str("folder")?, true)?; - std::fs::create_dir_all(&folder).map_err(|e| failed(e.to_string()))?; - let mut out = Vec::new(); - for p in a.strs("paths")? { - let name = std::path::Path::new(p).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "document.pdf".into()); - let result = self - .resolve(p, false) - .map_err(|e| e.to_string()) - .and_then(|src| crate::read_input_bytes(&src).map_err(|e| e.to_string())) - .and_then(|bytes| run_on(&action, &name, std::sync::Arc::new(bytes), |_, _| {})); - out.push(match result { - Ok(r) => { - let target = child(&folder, &name); - let target = if target.exists() { - let stem = - std::path::Path::new(&name).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| "document".into()); - unused(&folder, &stem) - } else { - target - }; - if target.exists() { - return Err(failed(format!("{}: no unused output name available", target.display()))); - } - write_atomic(&target, &r.bytes)?; - json!({ "path": p, "output": target.to_string_lossy(), "log": r.log }) - } - Err(e) => json!({ "path": p, "error": e }), - }); - } - Ok(json!({ "action": action.name, "files": out })) - } - - fn compare_ids(&self, a: &Args) -> Result<(pdfcraft_engine::DocId, pdfcraft_engine::DocId)> { - let new = self.doc(a)?.id; - let other = a.int("other")?; - let old = u64::try_from(other) - .ok() - .and_then(|o| self.session.get(pdfcraft_engine::DocId(o))) - .map(|d| d.id) - .ok_or_else(|| ToolError::InvalidArgs(format!("no open document {other}")))?; - Ok((old, new)) - } - - pub(crate) fn doc_compare(&self, a: &Args) -> Result { - use pdfcraft_engine::compare::Kind; - let (old, new) = self.compare_ids(a)?; - let c = self.session.compare(old, new).map_err(failed)?; - let limit = a.opt_int("limit")?.unwrap_or(500).max(1) as usize; - let r2 = |r: &[f64; 4]| r.map(|v| (v * 100.0).round() / 100.0); - let side = - |s: &pdfcraft_engine::compare::Side| json!({ "text": s.text, "page": s.page + 1, "rects": s.rects.iter().map(r2).collect::>() }); - let list: Vec = c - .changes - .iter() - .take(limit) - .map(|ch| json!({ "kind": ch.kind.label().to_lowercase(), "old": side(&ch.old), "new": side(&ch.new) })) - .collect(); - let visual: Vec = if a.opt_bool("visual")?.unwrap_or(false) { - self.session.compare_visual(old, new, 72.0).map_err(failed)?.iter().map(|(p, r)| json!({ "page": p + 1, "rect": r2(r) })).collect() - } else { - Vec::new() - }; - Ok(json!({ - "visual": visual, - "identical": c.identical(), - "replaced": c.count(Kind::Replaced), - "inserted": c.count(Kind::Inserted), - "deleted": c.count(Kind::Deleted), - "old_words": c.old_words, - "new_words": c.new_words, - "changes": list, - })) - } - - pub(crate) fn doc_compare_report(&self, a: &Args) -> Result { - let (old, new) = self.compare_ids(a)?; - let bytes = self.session.compare_report(old, new).map_err(failed)?; - let path = self.resolve(a.str("path")?, true)?; - write_atomic(&path, &bytes)?; - Ok(json!({ "path": path.to_string_lossy(), "bytes": bytes.len() })) - } - - pub(crate) fn doc_compare_mark(&mut self, a: &Args) -> Result { - let (old, new) = self.compare_ids(a)?; - let n = self.session.mark_differences(old, new).map_err(failed)?; - Ok(json!({ "comments": n })) - } - - pub(crate) fn form_detect_fields(&mut self, a: &Args) -> Result { - let pages = if a.opt_ints("pages")?.is_some() { self.pages(a, "pages")? } else { Vec::new() }; - let id = self.doc(a)?.id; - let found = self.session.detect_fields(id, &pages); - let list: Vec = found - .iter() - .map(|(p, c)| { - let (kind, comb) = match c.kind { - pdfcraft_engine::detect::Kind::Text => ("text", None), - pdfcraft_engine::detect::Kind::CheckBox => ("checkbox", None), - pdfcraft_engine::detect::Kind::Comb { cells } => ("text", Some(cells)), - }; - let mut v = json!({ "page": p + 1, "kind": kind, "name": c.name, "rect": c.rect.map(|v| (v * 100.0).round() / 100.0) }); - if let Some(cells) = comb { - v["comb"] = json!(cells); - } - v - }) - .collect(); - if a.opt_bool("add")?.unwrap_or(true) && !found.is_empty() { - self.session.auto_detect_fields(id, &pages).map_err(failed)?; - } - Ok(json!({ "fields": list })) - } - - pub(crate) fn form_actions(&self, a: &Args) -> Result { - use pdfcraft_engine::FieldAction as A; - let list: Vec = self - .doc(a)? - .field_actions(a.str("field")?) - .iter() - .map(|(t, act)| { - let mut v = match act { - A::JavaScript(j) => json!({ "javascript": j }), - A::Uri(u) => json!({ "url": u }), - A::Reset(f) => json!({ "reset": f }), - A::Named(n) => json!({ "menu": n }), - A::GoTo(p) => json!({ "page": p + 1 }), - A::ShowHide { fields, hide: true } => json!({ "hide": fields }), - A::ShowHide { fields, hide: false } => json!({ "show": fields }), - A::Submit(u) => json!({ "submit": u }), - A::Other(s) => json!({ "other": s }), - }; - v["trigger"] = json!(t.id()); - v - }) - .collect(); - Ok(json!({ "field": a.str("field")?, "actions": list })) - } - - pub(crate) fn form_set_actions(&mut self, a: &Args) -> Result { - use pdfcraft_engine::{FieldAction as A, FieldTrigger as T}; - let items = a.get("actions").and_then(Value::as_array).ok_or_else(|| ToolError::InvalidArgs("actions must be an array".into()))?; - let bad = |m: String| ToolError::InvalidArgs(m); - let names = |v: &Value| -> Vec { - v.as_array().map(|x| x.iter().filter_map(|s| s.as_str().map(str::to_string)).collect()).unwrap_or_default() - }; - let mut acts = Vec::new(); - for it in items { - let tid = it["trigger"].as_str().unwrap_or(""); - let t = T::from_id(tid).ok_or_else(|| bad(format!("unknown trigger {tid:?}")))?; - let act = if let Some(j) = it["javascript"].as_str() { - A::JavaScript(j.into()) - } else if let Some(u) = it["url"].as_str() { - A::Uri(u.into()) - } else if it.get("reset").is_some() { - A::Reset(names(&it["reset"])) - } else if let Some(m) = it["menu"].as_str() { - A::Named(m.into()) - } else if let Some(p) = it["page"].as_u64() { - A::GoTo((p.max(1) - 1) as usize) - } else if it.get("show").is_some() { - A::ShowHide { fields: names(&it["show"]), hide: false } - } else if it.get("hide").is_some() { - A::ShowHide { fields: names(&it["hide"]), hide: true } - } else if let Some(u) = it["submit"].as_str() { - A::Submit(u.into()) - } else { - return Err(bad(format!("{tid}: give one action (javascript, url, reset, menu, page, show, hide or submit)"))); - }; - acts.push((t, act)); - } - let name = a.str("field")?.to_string(); - let edit = - pdfcraft_engine::Edit::SetFieldProps { name, props: Box::new(pdfcraft_engine::FieldProps { actions: Some(acts), ..Default::default() }) }; - let id = self.doc(a)?.id; - self.session.apply(id, edit).map_err(failed)?; - self.form_actions(a) - } - - pub(crate) fn js_enabled(&mut self, a: &Args) -> Result { - if let Some(on) = a.opt_bool("enabled")? { - self.session.set_javascript(on); - } - Ok(json!({ "enabled": self.session.javascript() })) - } -} - #[cfg(test)] mod review_html_tests { use super::*; @@ -1098,21 +597,3 @@ mod review_html_tests { assert!(!html.contains(""), scheme("data")); - assert_eq!(document_url("ms-settings:privacy"), scheme("ms-settings")); - assert_eq!(document_url("smb://server/share"), scheme("smb")); - assert_eq!(document_url(""), Err(B::Empty)); - assert_eq!(document_url(" "), Err(B::Empty)); - for bad in [ - "example.org", - "//example.org", - "1http://x", - "ht tp://x", - "https:", - "https://", - "https:///path", - "https://user@/x", - "mailto:", - "https://trusted.example /x", - ] { - assert_eq!(document_url(bad), Err(B::Malformed), "{bad}"); - } - for hidden in [ - "https://exa\u{202E}gro.elpmaxe", - "https://example.org\u{200B}.evil", - "https://a.org/\u{0007}", - "https://a.org/x\ty", - "https://a.org/x\ny", - "https://example.org/a\u{00A0}b", - ] { - assert_eq!(document_url(hidden), Err(B::Hidden), "{hidden:?}"); - } - let long = format!("https://example.org/?q={}", "a".repeat(MAX_DOCUMENT_URL)); - assert!(matches!(document_url(&long), Err(B::TooLong(_)))); - } - - #[test] - fn the_host_shown_is_the_one_the_address_goes_to() { - use super::host; - assert_eq!(host("https://example.org/path"), Some("example.org")); - assert_eq!(host("https://trusted.example@other.example/"), Some("other.example")); - assert_eq!(host("http://user:pw@other.example:8080?x"), Some("other.example")); - assert_eq!(host("https://[::1]:8443/"), Some("[::1]")); - // Browsers read `\` as `/`, so this goes to other.example, not trusted.example. - assert_eq!(host("https://other.example\\@trusted.example/"), Some("other.example")); - assert_eq!(host("https://other.example\\x@trusted.example/"), Some("other.example")); - assert_eq!(host("mailto:a@example.org"), None); - assert_eq!(host("https:///nohost"), None); - } - - #[test] - fn addresses_the_url_parser_rejects_are_refused() { - use super::{BlockedLink as B, document_url}; - // The webbrowser crate would open each of these as a local file path. - for bad in ["mailto://a^b/x", "mailto://a:99999/", "mailto://[x]/", "mailto://a\\b/", "mailto://a b/", "https://a^b/", "http://ax"), scheme("data")); + assert_eq!(document_url("ms-settings:privacy"), scheme("ms-settings")); + assert_eq!(document_url("smb://server/share"), scheme("smb")); + assert_eq!(document_url(""), Err(B::Empty)); + assert_eq!(document_url(" "), Err(B::Empty)); + for bad in [ + "example.org", + "//example.org", + "1http://x", + "ht tp://x", + "https:", + "https://", + "https:///path", + "https://user@/x", + "mailto:", + "https://trusted.example /x", + ] { + assert_eq!(document_url(bad), Err(B::Malformed), "{bad}"); + } + for hidden in [ + "https://exa\u{202E}gro.elpmaxe", + "https://example.org\u{200B}.evil", + "https://a.org/\u{0007}", + "https://a.org/x\ty", + "https://a.org/x\ny", + "https://example.org/a\u{00A0}b", + ] { + assert_eq!(document_url(hidden), Err(B::Hidden), "{hidden:?}"); + } + let long = format!("https://example.org/?q={}", "a".repeat(MAX_DOCUMENT_URL)); + assert!(matches!(document_url(&long), Err(B::TooLong(_)))); +} + +#[test] +fn the_host_shown_is_the_one_the_address_goes_to() { + use super::host; + assert_eq!(host("https://example.org/path"), Some("example.org")); + assert_eq!(host("https://trusted.example@other.example/"), Some("other.example")); + assert_eq!(host("http://user:pw@other.example:8080?x"), Some("other.example")); + assert_eq!(host("https://[::1]:8443/"), Some("[::1]")); + // Browsers read `\` as `/`, so this goes to other.example, not trusted.example. + assert_eq!(host("https://other.example\\@trusted.example/"), Some("other.example")); + assert_eq!(host("https://other.example\\x@trusted.example/"), Some("other.example")); + assert_eq!(host("mailto:a@example.org"), None); + assert_eq!(host("https:///nohost"), None); +} + +#[test] +fn addresses_the_url_parser_rejects_are_refused() { + use super::{BlockedLink as B, document_url}; + // The webbrowser crate would open each of these as a local file path. + for bad in ["mailto://a^b/x", "mailto://a:99999/", "mailto://[x]/", "mailto://a\\b/", "mailto://a b/", "https://a^b/", "http://a { + name: String, + path: Option, + bytes: Arc>, + info: DocInfo, + structure: Result, Box>, + render_password: Option, + password: Option<&'a str>, + xfa: Option, +} + impl OpenContext { /// Prepare a document without mutating the session that supplied this context. pub fn prepare_open( @@ -241,7 +252,8 @@ impl Session { }; // XFA notes survive the document being re-read after edits. let xfa_warnings: Vec = if info.xfa.is_some() { info.warnings.clone() } else { Vec::new() }; - let mut document = Self::build_document(&context.trust, name, path, bytes, info, cos, render_password, password, xfa); + let mut document = + Self::build_document(&context.trust, DocumentOpenData { name, path, bytes, info, structure: cos, render_password, password, xfa }); // A script that ran away on open turns the form's scripts off for this document. document.xfa_template = if ran_away { None } else { xfa_template }; document.js_output.append(script_output); @@ -254,18 +266,8 @@ impl Session { } /// The last step of opening: build the document record and register it. - #[allow(clippy::too_many_arguments)] - fn build_document( - trust: &Arc, - name: String, - path: Option, - bytes: Arc>, - mut info: DocInfo, - cos: Result, Box>, - render_password: Option, - password: Option<&str>, - xfa: Option, - ) -> Document { + fn build_document(trust: &Arc, data: DocumentOpenData<'_>) -> Document { + let DocumentOpenData { name, path, bytes, mut info, structure: cos, render_password, password, xfa } = data; let config = RenderConfig { password: render_password.as_deref().map(Arc::from), ..Default::default() }; let (mut editor, read_only_reason) = match cos { Ok(Ok(cos)) => { @@ -302,7 +304,7 @@ impl Session { } } let display = display_bytes(editor.as_mut(), &bytes); - let renderer = RenderPool::new(display.clone(), render_threads(), config.clone()); + let renderer = RenderPool::new(display.clone(), render_threads(), display_config(&config)); let (form, adopted) = editor .as_ref() .map(|e| { diff --git a/crates/engine/src/optimizer.rs b/crates/engine/src/optimizer.rs index 42af737bab..30a14c5695 100644 --- a/crates/engine/src/optimizer.rs +++ b/crates/engine/src/optimizer.rs @@ -92,7 +92,24 @@ impl OptimizeJob { pdfcraft_xfa::clear_password_values(&mut cos).map_err(|e| EditError::Write(e.to_string()))?; let mut step = |stage| if progress(stage) { Ok(()) } else { Err(EditError::Cancelled) }; step(OptimizeStage::Discarding)?; - let discarded = if discard.is_empty() { Vec::new() } else { pdfcraft_redact::sanitize::remove_hidden(&mut cos, &discard)? }; + let discarded = if discard.is_empty() { + Vec::new() + } else { + // Every ticked category, as `remove_hidden` decides between them: with form fields + // ticked, discarding hidden text also takes the hidden text the flattened appearances + // bring onto the page, which no scan before flattening counts. It refuses when its scan + // counts every one as 0, which would end the whole optimization, and may have changed + // the copy by then (an empty outline root goes with bookmarks), so the copy from before + // is kept instead: nothing is discarded. + let before = cos.clone(); + match pdfcraft_redact::sanitize::remove_hidden(&mut cos, &discard) { + Err(pdfcraft_redact::RedactError::NothingToApply) => { + cos = before; + Vec::new() + } + r => r?, + } + }; let report = optimize::optimize_with_progress(&mut cos, &settings, &mut |s| { step(match s { optimize::Stage::Fragments => OptimizeStage::Fragments, diff --git a/crates/engine/src/pictures.rs b/crates/engine/src/pictures.rs index b29941720a..49d76f07c0 100644 --- a/crates/engine/src/pictures.rs +++ b/crates/engine/src/pictures.rs @@ -2,6 +2,8 @@ use std::sync::Arc; +use image::ImageDecoder; + use crate::EditError; /// A picture for a watermark or background (Acrobat: Source ▸ File): an image, or `page` @@ -57,3 +59,69 @@ pub(crate) fn mark_source(doc: &mut pdfcraft_cos::Document, f: &MarkFile) -> Res Ok(pdfcraft_edit::MarkSource { xobject, size, image: true }) } } + +/// The dimensions of an image from its bytes (JPEG, PNG, TIFF), without decoding the full raster. +pub fn image_dimensions(bytes: &[u8]) -> Option<(u32, u32)> { + let format = image::guess_format(bytes).ok()?; + let mut reader = image::ImageReader::with_format(std::io::Cursor::new(bytes), format); + let mut limits = image::Limits::default(); + limits.max_alloc = Some(64_000_000 * 4); + reader.limits(limits); + let mut decoder = reader.into_decoder().ok()?; + let (mut w, mut h) = image::ImageDecoder::dimensions(&decoder); + if w == 0 || h == 0 { + return None; + } + if format == image::ImageFormat::Jpeg + && u64::from(w) * u64::from(h) <= (64 << 20) + && let Ok(orientation) = decoder.orientation() + && matches!( + orientation, + image::metadata::Orientation::Rotate90 + | image::metadata::Orientation::Rotate270 + | image::metadata::Orientation::Rotate90FlipH + | image::metadata::Orientation::Rotate270FlipH + ) + { + std::mem::swap(&mut w, &mut h); + } + Some((w, h)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn phone_jpeg(w: u32, h: u32, orientation: u8) -> Vec { + use image::codecs::jpeg::JpegEncoder; + let mut jpeg = Vec::new(); + let mut encoder = JpegEncoder::new(&mut jpeg); + encoder.encode_image(&image::RgbImage::from_pixel(w, h, image::Rgb([20, 20, 20]))).expect("valid jpeg"); + let mut exif = b"Exif\0\0II\x2a\0\x08\0\0\0\x01\0\x12\x01\x03\0\x01\0\0\0".to_vec(); + exif.extend_from_slice(&[orientation, 0, 0, 0, 0, 0, 0, 0]); + let mut out = jpeg[..2].to_vec(); + out.extend_from_slice(&[0xff, 0xe1]); + out.extend_from_slice(&((exif.len() + 2) as u16).to_be_bytes()); + out.extend_from_slice(&exif); + out.extend_from_slice(&jpeg[2..]); + out + } + + #[test] + fn jpeg_exif_orientation_swaps_dimensions_for_quarter_turns() { + let turned_6 = phone_jpeg(60, 20, 6); + assert_eq!(image_dimensions(&turned_6), Some((20, 60))); + + let turned_8 = phone_jpeg(60, 20, 8); + assert_eq!(image_dimensions(&turned_8), Some((20, 60))); + + let upright_1 = phone_jpeg(60, 20, 1); + assert_eq!(image_dimensions(&upright_1), Some((60, 20))); + } + + #[test] + fn invalid_inputs_return_none() { + assert_eq!(image_dimensions(&[]), None); + assert_eq!(image_dimensions(b"not an image"), None); + } +} diff --git a/crates/engine/src/reexports.rs b/crates/engine/src/reexports.rs deleted file mode 100644 index 208a8e450b..0000000000 --- a/crates/engine/src/reexports.rs +++ /dev/null @@ -1,53 +0,0 @@ -//! What the engine re-exports from the other pdfcraft crates, so frontends need only this one. - -pub use pdfcraft_organize::{BoxSpec, FieldConflicts, PageBox, SplitBy, Transition, TransitionSpec, mime_for_name, split_ranges}; - -pub use pdfcraft_organize::LabelStyle; -pub use pdfcraft_organize::Standard; -pub use pdfcraft_organize::check_custom_key; -pub use pdfcraft_organize::view::{InitialView, Layout as InitialLayout, Magnification, Navigation}; - -pub use pdfcraft_cos::Algorithm; -pub use pdfcraft_create::{CONVERTIBLE, CreateError, ImageResolution, SourceKind, decode_text, source_kind}; -pub use pdfcraft_edit::added::{ - CHAR_SPACING_LIMIT as ADDED_CHAR_SPACING_LIMIT, paste_place as added_paste_place, paste_rect as added_paste_rect, text_rect as added_text_rect, - text_width as added_text_width, -}; -pub use pdfcraft_edit::{ - Added, AddedImage, AddedText, Align as TextAlign, Background, Content as AddedContent, Family as FontFamily, HeaderFooter, MarkKind, Watermark, -}; -pub use pdfcraft_forms::{ - BorderStyle, CalculatedField, CalculationIssue, CalculationOrder, 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, appearance as form_appearance, appearance::auto_text_size_by as field_auto_text_size_by, - calculations_changed as form_calculations_changed, flags as field_flags, -}; - -pub use pdfcraft_a11y as a11y; -pub use pdfcraft_edit::{BlockKey, BlockStyle, PageImage, RangeStyle, TextBlock, TextLine, block_overlapping, first_undrawable}; -pub use pdfcraft_measure as measure; -pub use pdfcraft_xfa::Report as XfaLayout; - -pub use pdfcraft_edit::{EditableObject, MAX_MOVE_OBJECTS, ObjectKind, ObjectTarget}; - -pub use pdfcraft_fonts::{MAX_SIGNATURE_CHARS, ScriptOutline, script_missing, script_outline, undrawable_message}; - -/// Comment geometry helpers (text-box line breaking) for frontends. -pub use pdfcraft_annot::appearance as annot_text; -pub use pdfcraft_annot::links::{Highlight as LinkHighlight, LinkAction, LinkItem, LinkStyle}; -pub use pdfcraft_annot::{ - AppearanceCache, AttachIcon, FillMark, LineEnding, Markup, NOTE_SIZE, NewAnnotation, NoteIcon, OverlayFont, OverlayLook, Props as CommentProps, - ReviewState, Rgb, Shape, StampGroup, StampKind, Style, rect_quad, -}; -pub use pdfcraft_forms::detect; -pub use pdfcraft_optimize as optimize; -pub use pdfcraft_preflight as pdfa; -pub use pdfcraft_print as print; -pub use pdfcraft_redact::codes::{CODE_SETS as REDACTION_CODE_SETS, CodeSet as RedactionCodeSet}; -pub use pdfcraft_redact::patterns::{ - MAX_WORDS as REDACT_MAX_WORDS, PATTERNS as REDACT_PATTERNS, Pattern as RedactPattern, find as find_pattern, word_list as redact_word_list, -}; -pub use pdfcraft_redact::sanitize::{HIDDEN, Hidden}; -pub use pdfcraft_sign as sign; -pub use pdfcraft_sign::{SignOptions, SignatureInfo, Status as SignatureStatus, TrustStore}; -pub use pdfcraft_xfdf::Format as DataFormat; diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index c3e34d90bd..9a4cb04dac 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -12,12 +12,17 @@ use crate::history::{Keys, MAX_UNDO, Scope, keys_after, scope_of, uses_scripts}; use crate::{ BookmarkPage, BookmarkRow, CombineSource, Combined, DataFormat, DocId, Document, Edit, EditError, Hidden, ImageResolution, LOW_RESOLUTION_PRINT_DPI, Label, ObjectKind, OptimizeReport, RecoverySnapshot, SignOptions, SourceKind, SplitPart, SummarySort, TrustStore, - XFA_REMOVED_PREFIX, XFA_SCRIPTS_OFF, apply_layer_state, comment_list, comment_summary, decode_text, display_bytes, guard, info_fields, js, - local_utc_offset, note_warnings, open_source, open_source_with, optimize, outline_rows, parse_ymd, print, print_raster, render_threads, - scan_colours, set_default_layer_state, shown_under_redactions, signatures_of, source_kind, sync_xfa_state, undrawn_notices, use_layer_choices, - writable, xfa, xfa_sync_datasets, + XFA_REMOVED_PREFIX, XFA_SCRIPTS_OFF, apply_layer_state, comment_list, comment_summary, decode_text, display_bytes, display_config, guard, + info_fields, js, local_utc_offset, note_warnings, open_source, open_source_with, optimize, outline_rows, parse_ymd, print, print_raster, + render_threads, scan_colours, set_default_layer_state, shown_under_redactions, signatures_of, source_kind, sync_xfa_state, undrawn_notices, + use_layer_choices, writable, xfa, xfa_sync_datasets, }; +mod editing; +mod files; +mod save; +mod view; + #[derive(Default)] pub struct Session { pub(crate) docs: Vec, @@ -41,1550 +46,3 @@ pub struct Session { /// [`Session::with_xfa_time_scale`]. pub(crate) xfa_time_scale: Option, } - -impl Session { - pub fn new() -> Self { - Self::default() - } - - /// Use a fixed clock (tests) instead of the system time. - pub fn with_clock(mut self, clock: fn() -> i64) -> Self { - self.clock = Some(clock); - self - } - - /// Multiply the time an XFA form's scripts may take by `factor` (tests): the 3 s the - /// scripts of an opening or a change share, the 5 s of a click's, the 10 s the script - /// engine has to start, and each script's own 1 s or 3 s. A test that checks what the - /// scripts do passes a large factor, so that a busy machine does not stop them. On native - /// builds, 0 stops every script before it starts; the wasm build has no wall clock, so - /// there the factor has no effect. - pub fn with_xfa_time_scale(mut self, factor: u32) -> Self { - self.xfa_time_scale = Some(factor); - 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) - } - - pub(crate) fn now(&self) -> Option { - if let Some(c) = self.clock { - return Some(c()); - } - #[cfg(not(target_arch = "wasm32"))] - return std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).ok().map(|d| d.as_secs() as i64); - #[cfg(target_arch = "wasm32")] - return None; - } - - /// Today's date in local time: (year, month, day). With an injected clock (tests) the clock - /// 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 = 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)); - const MONTHS: [&str; 12] = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; - let h12 = if hh % 12 == 0 { 12 } else { hh % 12 }; - let who = if author.trim().is_empty() { String::new() } else { format!("By {} ", author.trim()) }; - format!("{who}at {h12}:{mm:02} {}, {} {day:02}, {y}", if hh < 12 { "am" } else { "pm" }, MONTHS[(mo.clamp(1, 12) - 1) as usize]) - } - - pub fn today(&self) -> (i64, u32, u32) { - 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)) - } - - /// Open a document from bytes. Rendering starts lazily when pages are requested. - /// - /// `password` is tried as either the user or owner password when the file is encrypted. - /// Open saved revision `n` (1 = the oldest) of a document as a new, unsaved document named - /// " (revision n)". - pub fn open_revision(&mut self, id: DocId, n: usize) -> Result { - let doc = self.get(id).ok_or("no such document")?; - let ends = doc.revision_ends(); - let end = *n.checked_sub(1).and_then(|i| ends.get(i)).ok_or_else(|| format!("the document has {} revision(s)", ends.len()))?; - let name = format!("{} (revision {n}).pdf", doc.name.trim_end_matches(".pdf")); - let (bytes, password) = (Arc::new(doc.bytes[..end.min(doc.bytes.len())].to_vec()), doc.password.clone()); - self.open(name, None, bytes, password.as_deref()).map_err(|e| e.to_string()) - } - - pub(crate) fn doc_mut(&mut self, id: DocId) -> Result<&mut Document, EditError> { - self.docs.iter_mut().find(|d| d.id == id).ok_or(EditError::NoDocument) - } - - /// Apply an edit. On success the previous state is undoable and the view data is refreshed. - pub fn apply(&mut self, id: DocId, edit: Edit) -> Result<(), EditError> { - let now = self.now(); - let today = self.today(); - let js_off = self.js_off; - let xfa_time_scale = self.xfa_time_scale.unwrap_or(1); - let doc = self.doc_mut(id)?; - doc.edit_notices.clear(); - let name = doc.name.clone(); - let is_xfa = doc.info.xfa.is_some(); - let mut scope = scope_of(&edit); - let mut cx = EditCtx::new(now, doc.generation ^ (id.0 << 48)); - if !js_off { - cx.xfa = doc.xfa_template.clone(); - } - cx.xfa_time_scale = xfa_time_scale; - cx.pages = doc.info.pages.len(); - cx.today = today; - let reason = doc.read_only_reason.clone().unwrap_or_default(); - let signed = doc.is_signed(); - let editor = doc.editor.as_mut().ok_or(EditError::ReadOnly(reason))?; - if let Some(p) = editor.cos.permissions() { - check_permission(&edit, &p)?; - } - // Rewriting an OCR word (invisible text over a scan) also hides the scanned word: cover it - // in the paper's colour and draw the new text in the ink's. - let edit = match edit { - Edit::EditTextBlock { page, block, text, mut style } => { - // Only invisible text is sampled; the blocks are usually cached from opening it. - if style.cover.is_none() - && let Some(info) = doc.info.pages.get(page) - && let Some(b) = editor.text_blocks(page).ok().and_then(|b| b.get(block).filter(|b| b.invisible).cloned()) - && let Some((paper, ink)) = guard(|| scan_colours::sample(&editor.cos, info, page, &b)).ok().flatten() - { - style.cover = Some(paper); - style.color = style.color.or(Some(ink)); - } - Edit::EditTextBlock { page, block, text, style } - } - other => other, - }; - // Nothing to bake: leave undo history and the dirty flag alone. - if matches!(&edit, Edit::FlattenFillSign) && !pdfcraft_annot::has_visible_fill_sign(&editor.cos) { - return Ok(()); - } - let mut next = editor.cos.clone(); - if !js_off && uses_scripts(&edit) { - cx.js = Some(js::JsRunner::new(&next, &name)); - } - cx.renamed_fields.clear(); - cx.redaction_overlays.clear(); - // `next` is a copy: if the edit fails or crashes, the document is unchanged. - guard(|| run_edit(&mut next, &edit, &mut cx)) - .unwrap_or_else(|m| Err(EditError::Invalid(format!("{} failed unexpectedly ({m}); the document was not changed", edit.label()))))?; - // Redaction removed what its own interpreter places under the marks. The marks were drawn - // over the page as the renderer shows it, so the renderer has the last word. - if let Edit::ApplyRedactions { pages } = &edit { - let marks: Vec = - pdfcraft_redact::marks(&editor.cos).into_iter().filter(|m| pages.as_ref().is_none_or(|p| p.contains(&m.page))).collect(); - guard(|| shown_under_redactions(&next, &marks, &cx.redaction_overlays, &doc.info.pages, doc.password.as_deref())) - .unwrap_or_else(|m| Err(EditError::Invalid(format!("{} failed unexpectedly ({m}); the document was not changed", edit.label()))))?; - } - // Renamed fields are said, not slipped in: a repair-log note (with the caveat that - // scripts were left alone) and a count in the undo step's label. - let renamed_note = |renamed: &[(String, String)]| { - (!renamed.is_empty()).then(|| { - let shown: Vec = renamed.iter().take(5).map(|(old, new)| format!("{old} → {new}")).collect(); - let more = renamed.len().saturating_sub(shown.len()); - let more = if more > 0 { format!(", and {more} more") } else { String::new() }; - format!( - "inserted pages renamed {} form field{} to keep their names separate ({}{more}); scripts that refer to the old names were not rewritten", - renamed.len(), - if renamed.len() == 1 { "" } else { "s" }, - shown.join(", ") - ) - }) - }; - if let Some(note) = renamed_note(&cx.renamed_fields) { - next.note_repair(note); - } - // Only Insert from File renames fields; its label says so in a wording the interface - // translates (the count lives in the repair note). - let label = match (&edit, cx.renamed_fields.len()) { - (_, 0) => edit.label(), - (Edit::InsertPagesFrom { name, .. }, n) => Label::with( - if n == 1 { "Insert pages from {name} — 1 field renamed" } else { "Insert pages from {name} — fields renamed" }, - name.as_str(), - ), - _ => edit.label(), - }; - // An XFA form's scripts answer the change: exit and validate scripts of the field, then - // every calculation (and a reset recalculates). They read the data, so it is brought up - // to date first. - if let Some(tpl) = cx.xfa.clone() { - if is_xfa && matches!(scope_of(&edit), Scope::Form | Scope::Full | Scope::Content) { - let datasets = &mut cx.xfa_datasets; - let notes = guard(|| xfa_sync_datasets(&mut next, Some(&tpl), datasets)) - .unwrap_or_else(|m| Err(format!("writing the XFA data failed unexpectedly ({m})"))) - .map_err(EditError::Write)?; - cx.xfa_out.errors.extend(notes); - } - fn collect(e: &Edit, changed: &mut Vec, reset: &mut bool) { - match e { - Edit::SetFieldValue { name, .. } => changed.push(name.clone()), - Edit::ResetForm { .. } => *reset = true, - Edit::Batch { edits, .. } => edits.iter().for_each(|e| collect(e, changed, reset)), - _ => {} - } - } - let mut changed: Vec = Vec::new(); - let mut reset = false; - collect(&edit, &mut changed, &mut reset); - if !changed.is_empty() || reset { - let mut run = xfa::XfaRun { - page_count: cx.pages, - out: &mut cx.xfa_out, - datasets: cx.xfa_datasets, - ran_away: false, - time_scale: cx.xfa_time_scale, - }; - // Every changed field's scripts, then the calculations once (after a reset, - // only those). - let ran = guard(|| xfa::on_changes(&mut next, &tpl, &changed, &mut run)) - .unwrap_or_else(|m| Err(format!("the form's scripts failed unexpectedly ({m})"))); - cx.xfa_datasets = run.datasets; - cx.xfa_ran_away |= run.ran_away; - if let Err(e) = ran { - cx.xfa_out.errors.push(e); - } - } - } - // XFA forms keep their values in the datasets packet too, for Adobe's viewers. - let mut xfa_notes = Vec::new(); - if is_xfa && matches!(scope_of(&edit), Scope::Form | Scope::Full | Scope::Content) { - let (tpl, datasets) = (cx.xfa.as_deref(), &mut cx.xfa_datasets); - xfa_notes = guard(|| xfa_sync_datasets(&mut next, tpl, datasets)) - .unwrap_or_else(|m| Err(format!("writing the XFA data failed unexpectedly ({m})"))) - .map_err(EditError::Write)?; - // Rebuilding from stale data would discard accepted widget values. - if xfa_notes.is_empty() { - let reflow = guard(|| xfa::relayout_changed_text(&mut next, tpl, &doc.form)) - .unwrap_or_else(|m| Err(format!("laying out the XFA text failed unexpectedly ({m})"))) - .map_err(EditError::Write)?; - if let Some(warnings) = reflow { - xfa_notes.extend(warnings); - // Layout replaces pages and widgets, even if the page count is unchanged. - // Undo/redo must rebuild these too instead of refreshing only field values. - scope = Scope::Full; - } - } - } - // Signed documents are only ever saved incrementally: an edit that needs a full rewrite - // (applying redactions, changing security, sanitizing) would invalidate the signatures. - // So does any edit to a file rebuilt from object headers: it has no chain to append to. - let rewrites = |c: &pdfcraft_cos::Document| c.full_save_required() || c.encryption_changed(); - if signed && ((rewrites(&next) && !rewrites(&editor.cos)) || (next.is_modified() && next.revisions().is_empty())) { - return Err(EditError::SignedRewrite(edit.label().to_string())); - } - // What this edit drew with `?` (a value or comment text its font can't draw, stored as - // typed): reported, never silent (AGENTS.md §4). - let notices = if matches!(scope_of(&edit), Scope::Form | Scope::Comments | Scope::Full) { - guard(|| undrawn_notices(&editor.cos, &next)).unwrap_or_default() - } else { - Vec::new() - }; - let previous = std::mem::replace(&mut editor.cos, next); - let keys = keys_after(&edit).unwrap_or_else(|| editor.keys.clone()); - let previous_keys = std::mem::replace(&mut editor.keys, keys); - editor.undo.push((label, previous, previous_keys, scope)); - if editor.undo.len() > MAX_UNDO { - editor.undo.remove(0); - } - editor.redo.clear(); - Self::adopt_keys(doc); - let refreshed = guard(|| if scope == Scope::Content { Self::refresh_content(doc, Some(&edit)) } else { Self::refresh_scoped(doc, scope) }) - .unwrap_or_else(|m| Err(EditError::Reopen(m))); - if let Err(e) = refreshed { - // Roll back: the edit produced something we cannot display. - if let Some(ed) = doc.editor.as_mut() - && let Some((_, prev, keys, _)) = ed.undo.pop() - { - ed.cos = prev; - ed.keys = keys; - } - Self::adopt_keys(doc); - if !matches!(guard(|| Self::refresh(doc)), Ok(Ok(()))) { - Self::show_released(doc); - } - return Err(e); - } - 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); - if let Some(js) = cx.js { - doc.js_output.append(js.output); - } - doc.js_output.append(cx.xfa_out); - if cx.xfa_ran_away { - doc.xfa_template = None; - doc.xfa_scripts_off = true; - doc.js_output.errors.push(XFA_SCRIPTS_OFF.into()); - } - Ok(()) - } - - pub fn undo(&mut self, id: DocId) -> Result { - let doc = self.doc_mut(id)?; - let editor = doc.editor.as_mut().ok_or(EditError::NothingToUndo)?; - let (label, prev, keys, scope) = editor.undo.pop().ok_or(EditError::NothingToUndo)?; - let current = std::mem::replace(&mut editor.cos, prev); - let current_keys = std::mem::replace(&mut editor.keys, keys); - editor.redo.push((label.clone(), current, current_keys, scope)); - Self::adopt_keys(doc); - let refreshed = guard(|| Self::refresh_scoped(doc, scope)).unwrap_or_else(|m| Err(EditError::Reopen(m))); - if let Err(e) = refreshed { - if let Some(ed) = doc.editor.as_mut() - && let Some((label, current, current_keys, scope)) = ed.redo.pop() - { - let prev = std::mem::replace(&mut ed.cos, current); - let keys = std::mem::replace(&mut ed.keys, current_keys); - ed.undo.push((label, prev, keys, scope)); - } - Self::adopt_keys(doc); - if !matches!(guard(|| Self::refresh(doc)), Ok(Ok(()))) { - Self::show_released(doc); - } - return Err(e); - } - doc.edit_notices.clear(); - Self::sync_dirty(doc); - doc.generation += 1; - Ok(label) - } - - pub fn redo(&mut self, id: DocId) -> Result { - let doc = self.doc_mut(id)?; - let editor = doc.editor.as_mut().ok_or(EditError::NothingToRedo)?; - let (label, next, keys, scope) = editor.redo.pop().ok_or(EditError::NothingToRedo)?; - let current = std::mem::replace(&mut editor.cos, next); - let current_keys = std::mem::replace(&mut editor.keys, keys); - editor.undo.push((label.clone(), current, current_keys, scope)); - Self::adopt_keys(doc); - let refreshed = guard(|| Self::refresh_scoped(doc, scope)).unwrap_or_else(|m| Err(EditError::Reopen(m))); - if let Err(e) = refreshed { - if let Some(ed) = doc.editor.as_mut() - && let Some((label, current, current_keys, scope)) = ed.undo.pop() - { - let next = std::mem::replace(&mut ed.cos, current); - let keys = std::mem::replace(&mut ed.keys, current_keys); - ed.redo.push((label, next, keys, scope)); - } - Self::adopt_keys(doc); - if !matches!(guard(|| Self::refresh(doc)), Ok(Ok(()))) { - Self::show_released(doc); - } - return Err(e); - } - doc.edit_notices.clear(); - Self::sync_dirty(doc); - doc.generation += 1; - Ok(label) - } - - /// Unsaved when the working graph is not the last opened or saved file. Undo back to that - /// file is clean; undo past a later save is not, because the restored snapshot's own base - /// is an earlier revision. - fn sync_dirty(doc: &mut Document) { - doc.dirty = doc.editor.as_ref().is_some_and(|e| e.unsaved_file || e.cos.is_modified() || !Arc::ptr_eq(e.cos.bytes(), &e.saved)); - } - - /// Use the current state's password for viewing (protection edits change it). - fn adopt_keys(doc: &mut Document) { - if let Some(e) = doc.editor.as_ref() { - doc.password = e.keys.render.clone(); - doc.config.password = e.keys.render.as_deref().map(Arc::from); - } - } - - /// Rebuild the working bytes and the view data that `scope` may have changed. Metadata edits - /// keep the renderer; other scopes also replace it. - /// Comment and form edits skip the full re-inspection (seconds on very large files): the - /// comment list and field values are re-read from the object graph instead. - fn refresh_scoped(doc: &mut Document, scope: Scope) -> Result<(), EditError> { - if scope == Scope::Full || doc.info.encrypted != doc.editor.as_ref().is_some_and(|e| e.cos.output_handler().is_some()) { - return Self::refresh(doc); - } - if scope == Scope::Content { - return Self::refresh_content(doc, None); - } - if scope == Scope::Links { - return Self::refresh_links(doc); - } - // Metadata edits keep the renderer, which holds the working file anyway. - if scope != Scope::Metadata { - Self::release_working(doc); - } - let Some(editor) = doc.editor.as_mut() else { return Ok(()) }; - // Comment edits, and form edits (a script may change annotation properties), may change - // annotation dictionaries, invalidating cached appearances. - if matches!(scope, Scope::Comments | Scope::Form) { - editor.appearances.clear(); - } - let bytes = if editor.cos.is_modified() { - Arc::new(write_incremental(&editor.cos, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))?) - } else { - editor.cos.bytes().clone() - }; - if scope == Scope::Metadata { - for (field, value) in info_fields(&mut doc.info, &editor.cos) { - *field = value; - } - doc.info.custom = pdfcraft_organize::custom_info(&editor.cos); - doc.info.file_size = bytes.len(); - #[cfg(test)] - check_refresh_panic(); - if !doc.signatures.is_empty() { - let signatures = signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache); - doc.signatures = Arc::new(signatures); - } - doc.bytes = bytes; - return Ok(()); - } - let mut form = pdfcraft_forms::fields(&editor.cos); - xfa::mark_script_buttons(&editor.cos, &mut form); - let form = Arc::new(form); - match scope { - Scope::Metadata => return Ok(()), - Scope::Comments => { - doc.info.annotations = comment_list(&editor.cos); - // Link indexes are positions in /Annots, so comment insertion/deletion can move them. - doc.links = OnceLock::new(); - } - Scope::Form => { - // PERF: keystroke path, index once. A linear find per field is O(F^2); - // forms reach 10k fields. - let mut by_name = std::collections::HashMap::with_capacity(form.len()); - for x in form.iter() { - // First match wins (do not simplify to collect: it keeps the last). - by_name.entry(x.name.as_str()).or_insert(x); - } - for f in &mut doc.info.fields { - if let Some(ff) = by_name.get(f.name.as_str()) { - f.value = match ff.kind { - pdfcraft_forms::FieldKind::CheckBox | pdfcraft_forms::FieldKind::Radio => { - Some(ff.value.first().cloned().unwrap_or_else(|| "Off".into())) - } - _ if ff.value.is_empty() => None, - _ => Some(ff.value.join(", ")), - }; - } - } - } - Scope::Links => return Self::refresh_links(doc), - // Handled above; kept as a full refresh so the match stays total. - Scope::Full => return Self::refresh(doc), - // Handled above (content-only refresh); the arm keeps the match total. - Scope::Content => return Self::refresh_content(doc, None), - } - doc.info.file_size = bytes.len(); - doc.form = form; - let signatures = if doc.signatures.is_empty() { Vec::new() } else { signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache) }; - doc.display = display_bytes(Some(editor), &bytes); - doc.signatures = Arc::new(signatures); - doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); - doc.bytes = bytes; - Ok(()) - } - - /// Which derived lists a content-only edit can change: added content (`None`, from - /// undo/redo, re-reads both: always correct, rarely paid). - pub(crate) fn content_refresh_lists(edit: Option<&Edit>) -> (bool, bool) { - if let Some(Edit::Batch { edits, .. }) = edit { - // A batch of content edits (from a script, say) changes what its parts change. - return edits.iter().fold((false, false), |(added, marks), part| { - let (a, m) = Self::content_refresh_lists(Some(part)); - (added || a, marks || m) - }); - } - let touches_added = edit.is_none_or(|e| { - matches!( - e, - Edit::AddText { .. } - | Edit::AddImage { .. } - | Edit::UpdateContent { .. } - | Edit::DeleteContent { .. } - | Edit::ReplaceImage { .. } - | Edit::PasteContent { .. } - ) || matches!(e, Edit::MoveObjects { objects, .. } if objects.iter().any(|o| o.kind == ObjectKind::Added)) - }); - let touches_marks = edit.is_none_or(|e| { - matches!(e, Edit::AddHeaderFooter { .. } | Edit::AddWatermark { .. } | Edit::AddBackground { .. } | Edit::RemoveMarks { .. }) - }); - (touches_added, touches_marks) - } - - /// Rebuild working bytes, display bytes and renderer after a content-only edit: page - /// content streams, resources and added content/marks. The page tree, annotations, form - /// fields, links, outline and fonts are unchanged, so the full re-inspection (seconds on - /// very large files) is skipped. `edit` selects which derived lists are re-read (`None`, - /// from undo/redo, re-reads both: always correct, rarely paid). The cached display - /// 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(()) }; - let bytes = if editor.cos.is_modified() { - Arc::new(write_incremental(&editor.cos, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))?) - } else { - editor.cos.bytes().clone() - }; - // The edit must still leave a file the viewer can open; anything else rolls back above. - pdfcraft_render::check_pages(bytes.clone(), doc.password.as_deref(), doc.info.pages.len()).map_err(|e| EditError::Reopen(e.to_string()))?; - let (touches_added, touches_marks) = Self::content_refresh_lists(edit); - if touches_added || touches_marks { - doc.marker_scan = OnceLock::new(); - } - doc.info.file_size = bytes.len(); - let signatures = if doc.signatures.is_empty() { Vec::new() } else { signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache) }; - doc.display = display_bytes(Some(editor), &bytes); - doc.signatures = Arc::new(signatures); - doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); - doc.bytes = bytes; - doc.fonts_stale = true; - Ok(()) - } - - /// Let go of the working file before the next one is written (gap 13). The working file is - /// the original plus one appended revision, so on a 1 GiB file keeping the old one (in the - /// working bytes, the display bytes and the renderer) while writing the new one held three - /// copies at once; this keeps two. Until the refresh finishes, the working and display bytes - /// are the original's (already held by the editor, so free) and the renderer draws nothing. - /// A failed edit rolls back and refreshes again, which replaces all three; where a refresh - /// still fails, [`Self::show_released`] draws the original rather than nothing. Save writes - /// from the editor, never from these. - pub(crate) fn release_working(doc: &mut Document) { - let Some(original) = doc.editor.as_ref().map(|e| e.cos.bytes().clone()) else { return }; - // Dropping the pool stops its workers; each lets go of the file when it exits. - doc.renderer = RenderPool::new_inline(Arc::new(Vec::new()), doc.config.clone()); - doc.display = original.clone(); - doc.bytes = original; - #[cfg(test)] - check_refresh_panic(); - } - - /// After a refresh failed: draw the display bytes it left (the original, once released), - /// so the page still shows a document. - fn show_released(doc: &mut Document) { - doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); - } - - /// Rebuild working bytes, inspection and renderer from the current edit state. - pub(crate) fn refresh(doc: &mut Document) -> Result<(), EditError> { - Self::refresh_with_indexes(doc, true, true) - } - - /// Reinspect a link edit while preserving the page-content marker scan cache. - fn refresh_links(doc: &mut Document) -> Result<(), EditError> { - Self::refresh_with_indexes(doc, false, true) - } - - fn refresh_with_indexes(doc: &mut Document, invalidate_markers: bool, invalidate_links: bool) -> Result<(), EditError> { - // A full refresh may follow structural or annotation changes: cached appearances - // may be stale, so they are rebuilt below. - if let Some(editor) = doc.editor.as_mut() { - editor.appearances.clear(); - } - Self::release_working(doc); - sync_xfa_state(doc); - let Some(editor) = doc.editor.as_mut() else { return Ok(()) }; - let bytes = if editor.cos.is_modified() { - Arc::new(write_incremental(&editor.cos, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))?) - } else { - editor.cos.bytes().clone() - }; - let info = inspect(bytes.clone(), doc.password.as_deref()).map_err(|e| EditError::Reopen(e.to_string()))?; - // Keep the user's layer choices where the layers still exist. - let mut info = info; - for l in &mut info.layers { - if let Some(old) = doc.info.layers.iter().find(|o| o.id == l.id) { - l.visible = old.visible; - } - } - note_warnings(&mut info.warnings, &doc.xfa_warnings); - // As in `build_document`: the inspection reads only `/Info`, so fields stated only in XMP - // are filled in again after every full refresh. - for (field, value) in info_fields(&mut info, &editor.cos) { - if field.is_none() { - *field = value; - } - } - doc.info = info; - doc.fonts_stale = false; - let mut form = pdfcraft_forms::fields(&editor.cos); - xfa::mark_script_buttons(&editor.cos, &mut form); - doc.form = Arc::new(form); - if invalidate_markers { - doc.marker_scan = OnceLock::new(); - } - if invalidate_links { - doc.links = OnceLock::new(); - } - let signatures = signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache); - doc.display = display_bytes(Some(editor), &bytes); - doc.signatures = Arc::new(signatures); - doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); - doc.bytes = bytes; - Ok(()) - } - - /// The bytes to write for Save: an incremental update of the file as opened/last saved, - /// with `/ModDate` stamped. Call `mark_saved` after writing them successfully. - /// - /// A file whose cross-reference data had to be rebuilt is written in full even when nothing - /// was edited: its bytes as opened are the damaged file, and this save is how the repair the - /// user was told about reaches the disk (#1937). Not a signed one: a full rewrite would - /// break its signatures, so with no edits it keeps its bytes, damage and all. - pub fn save_bytes(&self, id: DocId) -> Result>, EditError> { - let doc = self.get(id).ok_or(EditError::NoDocument)?; - let Some(editor) = doc.editor.as_ref() else { return Ok(doc.bytes.clone()) }; - let on_saved = Arc::ptr_eq(editor.cos.bytes(), &editor.saved); - if !editor.cos.is_modified() && on_saved && (!editor.cos.reconstructed() || doc.is_signed()) { - return Ok(editor.saved.clone()); - } - // What the user removed again before saving (a cleared redaction mark) stays out of the file. - let opts = SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), drop_unlinked: true, ..SaveOptions::default() }; - // A restored snapshot can be based on an earlier revision. Append to the last saved - // file so that revision stays in the chain. A full rewrite (redaction, new encryption, - // a reconstructed file) still writes the current graph on its own. - let rewrite = editor.cos.full_save_required() || editor.cos.encryption_changed() || editor.cos.revisions().is_empty(); - let cos = writable(&editor.cos)?; - guard(|| { - if on_saved || rewrite { - write_incremental(&cos, &opts) - } else { - let mut base = pdfcraft_cos::Document::open_with_password(editor.saved.clone(), editor.saved_keys.reopen.as_deref())?; - base.overlay_changes_from(&cos)?; - write_incremental(&base, &opts) - } - }) - .map_err(EditError::Write)? - .map(Arc::new) - .map_err(|e| EditError::Write(e.to_string())) - } - - /// A compact, garbage-collected rewrite (Save As ▸ "Optimized" / Reduce File Size groundwork). - pub fn save_full_bytes(&self, id: DocId) -> Result>, EditError> { - let doc = self.get(id).ok_or(EditError::NoDocument)?; - if doc.is_signed() { - return Err(EditError::Signed); - } - let editor = doc.editor.as_ref().ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))?; - let opts = SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), ..SaveOptions::default() }; - let cos = writable(&editor.cos)?; - guard(|| write_full(&cos, &opts)).map_err(EditError::Write)?.map(Arc::new).map_err(|e| EditError::Write(e.to_string())) - } - - /// Check that `bytes`, from [`Self::save_bytes`] or [`Self::save_full_bytes`], reopen as this - /// document before they are written (#1698): [`Self::mark_saved`] only finds out after the - /// file has been replaced. Same error as `mark_saved`. - pub fn check_save(&self, id: DocId, bytes: &Arc>) -> Result<(), EditError> { - let doc = self.get(id).ok_or(EditError::NoDocument)?; - let Some(editor) = doc.editor.as_ref() else { return Ok(()) }; - pdfcraft_cos::Document::open_with_password(bytes.clone(), editor.keys.reopen.as_deref()).map_err(|e| EditError::Reopen(e.to_string()))?; - pdfcraft_render::check_pages(bytes.clone(), doc.password.as_deref(), doc.info.pages.len()).map_err(|e| EditError::Reopen(e.to_string())) - } - - /// Record a successful save of `bytes` (to `path`, if any): rebase editing on the saved file - /// so the next save appends only newer edits. Undo history is kept. - pub fn mark_saved(&mut self, id: DocId, bytes: Arc>, path: Option) -> Result<(), EditError> { - let doc = self.doc_mut(id)?; - if let Some(editor) = doc.editor.as_mut() { - // An encrypted file needs the password it is protected with now (the owner - // password when known, so saving never downgrades this session's rights). - let keys = editor.keys.clone(); - // The saved file carries no Password-field values (#3943, #3944); the open document keeps them. - let typed = pdfcraft_forms::password_values(&editor.cos); - editor.cos = - pdfcraft_cos::Document::open_with_password(bytes.clone(), keys.reopen.as_deref()).map_err(|e| EditError::Reopen(e.to_string()))?; - pdfcraft_forms::restore_password_values(&mut editor.cos, &typed).map_err(|e| EditError::Reopen(e.to_string()))?; - if !typed.is_empty() && doc.info.xfa.is_some() { - let mut datasets = None; - let _ = xfa_sync_datasets(&mut editor.cos, doc.xfa_template.as_deref(), &mut datasets); - } - editor.saved = bytes; - editor.saved_keys = keys; - editor.unsaved_file = false; - } - if let Some(p) = path { - doc.name = std::path::Path::new(&p).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| p.clone()); - doc.path = Some(p); - } - doc.dirty = false; - doc.generation += 1; - Self::refresh(doc).inspect_err(|_| Self::show_released(doc)) - } - - /// Enter the permissions (owner) password of a document opened with its user password, - /// lifting its restrictions for this session (Acrobat asks for it before changing security). - /// Not an edit: nothing is marked unsaved and there is no undo step. Undo and redo states - /// are unlocked as well, and saving keeps the rights. Unencrypted documents and documents - /// opened by the owner need nothing. - pub fn unlock(&mut self, id: DocId, password: &str) -> Result<(), EditError> { - let doc = self.doc_mut(id)?; - let editor = doc.editor.as_mut().ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))?; - if editor.cos.permissions().is_none_or(|p| p.owner) { - return Ok(()); - } - editor.cos.unlock(password).map_err(|e| match e { - pdfcraft_cos::CosError::WrongPassword => EditError::WrongPassword, - other => EditError::Protection(other.to_string()), - })?; - // Saving a state that keeps the security it was opened with reopens it with the same - // passwords, so it reopens as owner. - let reopen_as_owner = |cos: &pdfcraft_cos::Document, keys: &mut Keys| { - if !cos.encryption_changed() { - keys.reopen = Some(password.to_owned()); - } - }; - reopen_as_owner(&editor.cos, &mut editor.keys); - // Other states with the same restricted access are unlocked too; states opened with - // other passwords (protection changed and saved since) stay as they are. - for (_, cos, keys, _) in editor.undo.iter_mut().chain(editor.redo.iter_mut()) { - if cos.permissions().is_some_and(|p| !p.owner) && cos.unlock(password).is_ok() { - reopen_as_owner(cos, keys); - } - } - Ok(()) - } - - /// File ▸ Revert: back to the last saved version (or the file as opened). Undo history is - /// cleared, as in Acrobat. - pub fn revert(&mut self, id: DocId) -> Result<(), EditError> { - let doc = self.doc_mut(id)?; - let editor = doc.editor.as_mut().ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))?; - let base = editor.saved.clone(); - // The saved file opens with the passwords it was saved with. The current snapshot may - // be an older revision (undo past that save), so its own base is not the saved file. - let mut candidates = vec![editor.saved_keys.clone(), editor.keys.clone()]; - candidates.extend(editor.undo.iter().rev().map(|s| s.2.clone())); - candidates.push(Keys::default()); - let (cos, keys) = candidates - .into_iter() - .find_map(|k| pdfcraft_cos::Document::open_with_password(base.clone(), k.reopen.as_deref()).ok().map(|c| (c, k))) - .ok_or_else(|| EditError::Reopen("the saved file can't be opened".into()))?; - editor.cos = cos; - editor.keys = keys; - editor.undo.clear(); - editor.redo.clear(); - Self::adopt_keys(doc); - Self::sync_dirty(doc); - doc.generation += 1; - Self::refresh(doc).inspect_err(|_| Self::show_released(doc)) - } - - /// The document's file changed on disk (a LaTeX or Typst build rewrote it, #431): open - /// `bytes`, the file as it is now, in its place. Everything opening does runs again (XFA - /// layout and scripts, signatures, repairs), but the document keeps its id, name and path, - /// so its tab and view stay with it. Undo history goes, as with Revert. The password it was - /// opened with is tried; on any failure the document is left as it was. - pub fn reload(&mut self, id: DocId, bytes: Arc>) -> Result<(), OpenError> { - let old = self.docs.iter().position(|d| d.id == id).ok_or_else(|| OpenError::Invalid("the document is not open".into()))?; - let doc = &self.docs[old]; - let (name, path, generation) = (doc.name.clone(), doc.path.clone(), doc.generation); - let password = doc.editor.as_ref().and_then(|e| e.keys.reopen.clone()).or_else(|| doc.password.clone()); - let new_id = self.open(name, path, bytes, password.as_deref())?; - let at = self.docs.iter().position(|d| d.id == new_id).ok_or_else(|| OpenError::Invalid("the new version was not kept".into()))?; - let mut doc = self.docs.remove(at); - doc.id = id; - // How the document is shown is the reader's, not the file's: reading colours and hidden - // comments stay as they were (#3443). `open` built the renderer with the defaults. - if let Some(old) = self.docs.get(old) - && (doc.config.reading_colors != old.config.reading_colors || doc.config.hide_comments != old.config.hide_comments) - { - doc.config.reading_colors = old.config.reading_colors; - doc.config.hide_comments = old.config.hide_comments; - doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); - } - // Caches keyed by the edit generation (edit lines, images, measurements) must see a new - // one; there is nothing new to autosave. - doc.generation = generation + 1; - doc.snapshot_generation = doc.generation; - // `open` pushed the new version last, so the old one is still at `old`. - *self.docs.get_mut(old).ok_or_else(|| OpenError::Invalid("the document is not open".into()))? = doc; - Ok(()) - } - - /// The page count of another PDF (Replace Pages, Insert Pages dialogs). - pub fn page_count_of(&self, name: &str, bytes: &Arc>) -> Result { - Ok(pdfcraft_organize::page_count(&open_source(name, bytes)?)?) - } - - /// A new blank document (Create ▸ Blank page). - pub fn create_blank(&self, width: f64, height: f64, pages: usize) -> Result>, EditError> { - self.write_new(&pdfcraft_create::blank(width, height, pages)?) - } - - /// A new document with one page per image (PNG, JPEG). - pub fn create_from_images(&self, images: &[(String, Vec)]) -> Result>, EditError> { - self.write_new(&pdfcraft_create::from_images(images)?) - } - - /// Create image pages at embedded resolution or a fixed dpi, without resampling. - pub fn create_from_images_with_resolution(&self, images: &[(String, Vec)], resolution: ImageResolution) -> Result>, EditError> { - self.write_new(&pdfcraft_create::from_images_with_resolution(images, resolution)?) - } - - /// A new document from plain text (US Letter, 11 pt Helvetica). Text Helvetica can't draw is - /// refused, naming the line and the character (never set as `?`). - pub fn create_from_text(&self, title: &str, text: &str) -> Result>, EditError> { - self.write_new(&pdfcraft_create::from_text(title, text, pdfcraft_create::LETTER, 11.0)?) - } - - /// [`Self::create_from_text`] for a report PdfCraft writes from documents (comment summaries, - /// compare reports), which can quote any language: characters Helvetica can't draw are written - /// as `[U+XXXX]`, and the report's first line says so, rather than the report being refused. - pub(crate) fn create_report(&self, title: &str, text: &str) -> Result>, EditError> { - let (doc, _escaped) = pdfcraft_create::from_text_escaped(title, text, pdfcraft_create::LETTER, 11.0)?; - self.write_new(&doc) - } - - /// Convert a file Create understands (an image or plain text) to PDF bytes; a PDF is checked - /// (it must open and allow copying pages) and returned as it is. - pub fn convert_to_pdf(&self, name: &str, bytes: &Arc>) -> Result<(SourceKind, Arc>), EditError> { - let Some(kind) = source_kind(name, bytes) else { - return Err(EditError::Source(format!("{name}: this file type can't be converted; use a PDF, an image or a .txt file"))); - }; - let title = name.rsplit_once('.').map_or(name, |(s, _)| s); - // Image decoders read untrusted bytes: a panic in one must not take the app down. - let created = guard(|| match kind { - SourceKind::Pdf => open_source(name, bytes).map(|_| bytes.clone()), - SourceKind::Image => self.create_from_images(&[(name.to_string(), bytes.to_vec())]), - SourceKind::Text => self.create_from_text(title, &decode_text(bytes)), - }) - .map_err(|_| EditError::Source(format!("{name}: the file could not be read")))?; - Ok((kind, created?)) - } - - /// A file for Combine as PDF bytes: an image or text file is converted; a PDF is returned as - /// it is, unchecked (Combine reads it itself, with its password), as is a file named .pdf - /// without a `%PDF-` header, which the reader may still open. - pub fn as_pdf(&self, name: &str, bytes: &Arc>) -> Result>, EditError> { - let pdf = match source_kind(name, bytes) { - Some(SourceKind::Pdf) => true, - Some(SourceKind::Image | SourceKind::Text) => false, - None => name.to_ascii_lowercase().ends_with(".pdf"), - }; - if pdf { Ok(bytes.clone()) } else { self.convert_to_pdf(name, bytes).map(|(_, pdf)| pdf) } - } - - /// Reduce File Size: Acrobat's defaults (images above 225 ppi to 150 ppi, JPEG medium - /// quality; thumbnails dropped), identical resources merged, unused objects dropped, objects - /// packed into compressed object streams. Returns the bytes and how many objects were - /// merged. The open document is not changed (Acrobat saves the reduced copy as a new file). - pub fn reduced_bytes(&self, id: DocId) -> Result<(Arc>, usize), EditError> { - let (bytes, report) = self.optimized_bytes(id, &optimize::Settings::default(), &[])?; - Ok((bytes, report.merged)) - } - - /// Optimize PDF ▸ Advanced optimization: `settings` for images and objects, plus Remove - /// Hidden Information's `discard` categories (user data). A full rewrite: signed documents - /// are refused. The open document is not changed. This is [`Self::optimize_job`] run in - /// place, without progress. - pub fn optimized_bytes(&self, id: DocId, settings: &optimize::Settings, discard: &[Hidden]) -> Result<(Arc>, OptimizeReport), EditError> { - self.optimize_job(id, settings, discard)?.run(|_| true) - } - - /// Export comments and/or form data: XFDF and FDF carry either or both; XML, CSV and text - /// are form data only. - pub fn export_data(&self, id: DocId, format: DataFormat, comments: bool, fields: bool) -> Result, EditError> { - let doc = self.get(id).ok_or(EditError::NoDocument)?; - let cos = match doc.editor.as_ref() { - Some(e) => e.cos.clone(), - None => { - pdfcraft_cos::Document::open_with_password(doc.bytes.clone(), doc.password.as_deref()).map_err(|e| EditError::Write(e.to_string()))? - } - }; - // An XFA form's data is its datasets, as Acrobat exports it: XML or an XDP. - let xfa = doc.info.xfa.is_some(); - if format == DataFormat::Xdp && !xfa { - return Err(EditError::Invalid("XDP holds XFA form data; this document is not an XFA form (use .xml, .csv or .txt)".into())); - } - guard(|| match format { - DataFormat::Xfdf => Ok(pdfcraft_xfdf::export_xfdf(&cos, comments, fields, &doc.name).into_bytes()), - DataFormat::Fdf => pdfcraft_xfdf::export_fdf(&cos, comments, fields, &doc.name).map_err(|e| e.to_string()), - DataFormat::Xml | DataFormat::Xdp if xfa => { - pdfcraft_xfa::export_data(&cos, format == DataFormat::Xdp, &doc.name).map(String::into_bytes).map_err(|e| e.to_string()) - } - other => Ok(pdfcraft_xfdf::export_data(&cos, other).into_bytes()), - }) - .map_err(EditError::Write)? - .map_err(EditError::Write) - } - - /// The print-ready PDF for `settings` (sheets laid out for the paper; see `pdfcraft-print`). - pub fn print_pdf(&self, id: DocId, settings: &print::Settings) -> Result, EditError> { - let doc = self.get(id).ok_or(EditError::NoDocument)?; - if !doc.allows_printing() { - return Err(EditError::NotPermitted("printing")); - } - // Table 22, bit 12 clear: only a low-level representation may be printed, so the pages - // always go as pictures, at no more than the low resolution. - let low_res = doc.permissions().is_some_and(|p| !p.print_high_quality()).then(|| print::Raster { - dpi: settings.raster.map_or(LOW_RESOLUTION_PRINT_DPI, |r| r.dpi.min(LOW_RESOLUTION_PRINT_DPI)), - grayscale: settings.raster.is_some_and(|r| r.grayscale), - }); - // 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) = low_res.as_ref().or(settings.raster.as_ref()) { - let mut distinct: Vec = Vec::new(); - for &p in &settings.pages { - if !distinct.contains(&p) { - distinct.push(p); - } - } - let bytes = print_raster::rasterize(doc, &distinct, raster, settings.content).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 mut cos = match doc.editor.as_ref() { - Some(e) => e.cos.clone(), - None => { - pdfcraft_cos::Document::open_with_password(doc.bytes.clone(), doc.password.as_deref()).map_err(|e| EditError::Write(e.to_string()))? - } - }; - // #3046: the layers print as they are shown. Once the Layers panel (or a set-layer - // action) has changed one, the print copy's default configuration says which are on, so a - // hidden layer stays off on paper too; untouched, the document's own configuration prints. - if !doc.config.layers.is_empty() { - set_default_layer_state(&mut cos, &doc.info.layers); - } - pdfcraft_print::impose(&cos, settings).map_err(|e| EditError::Print(e.to_string())) - } - - /// For each page, whether its text mostly runs bottom-to-top ([`pdfcraft_print::text_reads_up`]); - /// only `pages` are read, the others are `false`. - pub fn print_text_up(&self, id: DocId, pages: &[usize]) -> Vec { - let Some(doc) = self.get(id) else { return Vec::new() }; - let cos = match doc.editor.as_ref() { - Some(e) => e.cos.clone(), - None => match pdfcraft_cos::Document::open_with_password(doc.bytes.clone(), doc.password.as_deref()) { - Ok(c) => c, - Err(_) => return Vec::new(), - }, - }; - (0..doc.info.pages.len()).map(|i| pages.contains(&i) && pdfcraft_print::text_reads_up(&cos, i)).collect() - } - - /// Combine whole files, in order, into new PDF bytes (one bookmark per file). - pub fn combine(&self, sources: &[(String, Arc>)]) -> Result>, EditError> { - let docs = sources.iter().map(|(n, b)| open_source(n, b)).collect::, _>>()?; - let named: Vec<(&str, &pdfcraft_cos::Document)> = sources.iter().map(|(n, _)| n.as_str()).zip(docs.iter()).collect(); - let out = pdfcraft_organize::combine(&named)?; - self.write_new(&out) - } - - /// Combine Files with a page range per file ("1-3, 6"; `None` or empty for all pages). - pub fn combine_ranges(&self, sources: &[CombineSource]) -> Result>, EditError> { - self.combine_unlocked(sources, &[]) - } - - /// Combine sources one at a time, in order (Create ▸ Multiple files), to the same result as - /// [`Self::combine_ranges`]. Each item is a source's bookmark title, its PDF bytes (see - /// [`Self::convert_to_pdf`]) and its page range (`None` for all pages). Items are pulled one at a - /// time: a source is opened, its pages are copied into the result, and its bytes are released - /// before the next item is read. So the number of sources is not limited, and one source's - /// document is held at a time. An item's error stops the combine and is returned as it is. - /// `Ok(None)` when there are no sources. - pub fn combine_each(&self, sources: I) -> Result>>, E> - where - I: IntoIterator>, - E: From, - { - let mut combiner = pdfcraft_organize::Combiner::new(); - let mut added = false; - for source in sources { - let (title, bytes, range) = source?; - let doc = open_source(&title, &bytes)?; - let range = range.as_deref().map(str::trim).filter(|r| !r.is_empty()); - let pages = match range { - Some(r) => { - let n = pdfcraft_organize::page_count(&doc).map_err(EditError::from)?; - let p = pdfcraft_print::select_pages(n, Some(r), &[], pdfcraft_print::Subset::All, false) - .map_err(|e| EditError::Print(format!("{title}: {e}")))?; - Some(p) - } - None => None, - }; - combiner.add(&title, &doc, pages.as_deref()).map_err(EditError::from)?; - added = true; - } - if !added { - return Ok(None); - } - let out = combiner.finish().map_err(EditError::from)?; - Ok(Some(self.write_new(&out)?)) - } - - /// [`Self::combine_ranges`] with the password each encrypted source is opened with, by - /// position (missing or `None`: no password). The result is not encrypted. - pub fn combine_unlocked(&self, sources: &[CombineSource], passwords: &[Option<&str>]) -> Result>, EditError> { - Ok(self.combine_unlocked_reporting(sources, passwords, pdfcraft_organize::FieldConflicts::Rename)?.bytes) - } - - /// The pre-flight over byte sources: what combining them, in order, would rename in each one - /// (whole files; page ranges are the combine's to apply). A source that cannot be opened - /// previews as nothing — its row already says why it cannot be combined. - pub fn combine_field_conflicts(&self, sources: &[CombineSource], passwords: &[Option<&str>]) -> Vec> { - let docs: Vec> = - sources.iter().enumerate().map(|(i, (n, b, _))| open_source_with(n, b, passwords.get(i).copied().flatten()).ok()).collect(); - let refs: Vec<&pdfcraft_cos::Document> = docs.iter().filter_map(Option::as_ref).collect(); - let mut opened = pdfcraft_organize::combine_field_conflicts(&refs).into_iter(); - // One preview per source, in step with it: a file that cannot be opened has none. - docs.iter().map(|d| if d.is_some() { opened.next().unwrap_or_default() } else { Default::default() }).collect() - } - - /// [`Self::combine_unlocked`] reporting what the conflict resolution renamed. - pub fn combine_unlocked_reporting( - &self, - sources: &[CombineSource], - passwords: &[Option<&str>], - field_conflicts: pdfcraft_organize::FieldConflicts, - ) -> Result { - let docs = sources - .iter() - .enumerate() - .map(|(i, (n, b, _))| open_source_with(n, b, passwords.get(i).copied().flatten())) - .collect::, _>>()?; - let mut pages = Vec::with_capacity(docs.len()); - for ((name, _, range), d) in sources.iter().zip(&docs) { - let range = range.as_deref().map(str::trim).filter(|r| !r.is_empty()); - pages.push(match range { - Some(r) => { - let n = pdfcraft_organize::page_count(d)?; - let p = pdfcraft_print::select_pages(n, Some(r), &[], pdfcraft_print::Subset::All, false) - .map_err(|e| EditError::Print(format!("{name}: {e}")))?; - Some(p) - } - None => None, - }); - } - let named: Vec<(&str, &pdfcraft_cos::Document, Option<&[usize]>)> = - sources.iter().zip(docs.iter()).zip(&pages).map(|(((n, _, _), d), p)| (n.as_str(), d, p.as_deref())).collect(); - let out = pdfcraft_organize::combine_selected_with(&named, field_conflicts)?; - let bytes = self.write_new(&out.doc)?; - Ok(Combined { bytes, renamed_fields: out.renamed_fields }) - } - - /// [`Self::combine_unlocked`] where sources with the same `groups` key (by position) are one - /// file split into several runs placed apart: each file is opened and copied once, so it keeps - /// one bookmark, its links between its own pages, its fields and its attachments whole (see - /// `pdfcraft_organize::combine_grouped`). A group's later sources use its first source's bytes - /// and password; a source without a key is a file of its own. - pub fn combine_grouped(&self, sources: &[CombineSource], groups: &[u64], passwords: &[Option<&str>]) -> Result>, EditError> { - Ok(self.combine_grouped_reporting(sources, groups, passwords, pdfcraft_organize::FieldConflicts::Rename)?.bytes) - } - - /// [`Self::combine_grouped`] reporting what the conflict resolution renamed. - pub fn combine_grouped_reporting( - &self, - sources: &[CombineSource], - groups: &[u64], - passwords: &[Option<&str>], - field_conflicts: pdfcraft_organize::FieldConflicts, - ) -> Result { - let (mut keys, mut docs, mut file): (Vec>, Vec, Vec) = (Vec::new(), Vec::new(), Vec::new()); - for (i, (name, bytes, _)) in sources.iter().enumerate() { - let key = groups.get(i).copied(); - let known = key.and_then(|k| keys.iter().position(|g| *g == Some(k))); - let at = match known { - Some(at) => at, - None => { - docs.push(open_source_with(name, bytes, passwords.get(i).copied().flatten())?); - keys.push(key); - docs.len() - 1 - } - }; - file.push(at); - } - let mut pages = Vec::with_capacity(sources.len()); - for ((name, _, range), at) in sources.iter().zip(&file) { - let range = range.as_deref().map(str::trim).filter(|r| !r.is_empty()); - let Some(d) = docs.get(*at) else { return Err(EditError::Write(format!("{name}: not opened"))) }; - pages.push(match range { - Some(r) => { - let n = pdfcraft_organize::page_count(d)?; - let p = pdfcraft_print::select_pages(n, Some(r), &[], pdfcraft_print::Subset::All, false) - .map_err(|e| EditError::Print(format!("{name}: {e}")))?; - Some(p) - } - None => None, - }); - } - let mut runs: Vec> = Vec::with_capacity(sources.len()); - for (((name, _, _), at), p) in sources.iter().zip(&file).zip(&pages) { - let Some(d) = docs.get(*at) else { return Err(EditError::Write(format!("{name}: not opened"))) }; - runs.push((*at, name.as_str(), d, p.as_deref())); - } - let out = pdfcraft_organize::combine_grouped_with(&runs, field_conflicts)?; - let bytes = self.write_new(&out.doc)?; - Ok(Combined { bytes, renamed_fields: out.renamed_fields }) - } - - /// New PDF bytes containing copies of `pages` of the document (Extract Pages). - pub fn extract(&self, id: DocId, pages: &[usize]) -> Result>, EditError> { - let src = self.cos(id)?; - if src.permissions().is_some_and(|p| !p.assemble()) { - return Err(EditError::NotPermitted("extracting pages")); - } - let out = pdfcraft_organize::extract_pages(src, pages)?; - self.write_new(&out) - } - - /// Split the document into several new PDFs. - pub fn split(&self, id: DocId, by: &pdfcraft_organize::SplitBy) -> Result, EditError> { - let src = self.cos(id)?; - if src.permissions().is_some_and(|p| !p.assemble()) { - return Err(EditError::NotPermitted("splitting the document")); - } - let n = pdfcraft_organize::page_count(src)?; - pdfcraft_organize::split_ranges(n, by) - .into_iter() - .map(|r| { - let doc = pdfcraft_organize::extract_pages(src, &r.clone().collect::>())?; - Ok((r.start + 1, r.end, self.write_new(&doc)?)) - }) - .collect() - } - - /// Split into parts of at most `max_bytes` each (Acrobat's "File size"): pages are taken in - /// order while their size (as single-page files, an over-estimate because shared fonts and - /// images count once per part) fits; a page larger than the limit is a part of its own. - pub fn split_by_size(&self, id: DocId, max_bytes: usize) -> Result, EditError> { - let src = self.cos(id)?; - if src.permissions().is_some_and(|p| !p.assemble()) { - return Err(EditError::NotPermitted("splitting the document")); - } - let n = pdfcraft_organize::page_count(src)?; - let mut cuts = Vec::new(); - let mut used = 0usize; - for p in 0..n { - let size = self.write_unchecked(&pdfcraft_organize::extract_pages(src, &[p])?)?.len(); - if used > 0 && used + size > max_bytes { - cuts.push(p); - used = 0; - } - used += size; - } - self.split(id, &pdfcraft_organize::SplitBy::Before(cuts)) - } - - /// Web addresses in the text of every page (Create links from URLs): (page, one rectangle - /// per line in user space, URI). Text that already has a link over it is skipped. - pub fn find_urls(&self, id: DocId) -> Vec<(usize, Vec<[f64; 4]>, String)> { - let Some(doc) = self.get(id) else { return Vec::new() }; - let config = RenderConfig { password: doc.password.as_deref().map(Arc::from), ..Default::default() }; - let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), config); - let mut out = Vec::new(); - for page in 0..doc.info.pages.len() { - let res = r.render(pdfcraft_render::RenderRequest { page, kind: pdfcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() }); - let Some(text) = res.text else { continue }; - let found: std::cell::RefCell, String)>> = Default::default(); - let hits = text.find_with(|chars| { - let urls = pdfcraft_annot::links::find_urls(chars); - let ranges = urls.iter().map(|u| u.0.clone()).collect(); - *found.borrow_mut() = urls; - ranges - }); - for (glyphs, (_, uri)) in hits.into_iter().zip(found.into_inner()) { - let rects: Vec<[f64; 4]> = text - .line_rects(glyphs) - .into_iter() - .map(|v| { - let q = doc.info.pages[page].view_rect_to_quad(v); - let xs = [q[0], q[2], q[4], q[6]]; - let ys = [q[1], q[3], q[5], q[7]]; - [ - xs.iter().copied().fold(f64::MAX, f64::min), - ys.iter().copied().fold(f64::MAX, f64::min), - xs.iter().copied().fold(f64::MIN, f64::max), - ys.iter().copied().fold(f64::MIN, f64::max), - ] - }) - .filter(|r| { - !doc.links().iter().any(|l| l.page == page && l.rect[0] < r[2] && r[0] < l.rect[2] && l.rect[1] < r[3] && r[1] < l.rect[3]) - }) - .collect(); - if !rects.is_empty() { - out.push((page, rects, uri)); - } - } - } - out - } - - /// A page of the bookmark list in outline order: up to `limit` bookmarks from position `offset`. - /// The document's own structure is read for it, so every bookmark can be reached by paging. - pub fn bookmark_page(&self, id: DocId, offset: usize, limit: usize) -> Option { - 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| { - 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, color: b.color, italic: b.italic, bold: b.bold } - }) - .collect(); - return Some(BookmarkPage { rows, next: page.next, truncated: false }); - } - let all = outline_rows(&doc.info.outline); - let end = offset.saturating_add(limit); - let rows = all.iter().skip(offset).take(limit).cloned().collect(); - Some(BookmarkPage { rows, next: (end < all.len()).then_some(end), truncated: doc.info.outline_more }) - } - - /// Top-level bookmarks as split points: (first page of each part, its bookmark's title). - pub fn bookmark_splits(&self, id: DocId) -> Vec<(usize, String)> { - let Some(doc) = self.get(id) else { return Vec::new() }; - let top: Vec<(Option, String)> = match doc.editor.as_ref() { - Some(editor) => pdfcraft_organize::top_level_bookmarks(&editor.cos).into_iter().map(|b| (b.page, b.title)).collect(), - None => doc.info.outline.iter().map(|o| (o.page, o.title.clone())).collect(), - }; - let mut out: Vec<(usize, String)> = top.into_iter().filter_map(|(page, title)| Some((page?, title))).collect(); - out.sort_by_key(|x| x.0); - out.dedup_by_key(|x| x.0); - out - } - - /// Open freshly created bytes (combine / extract) as a new, unsaved document. - pub fn open_new(&mut self, name: impl Into, bytes: Arc>) -> Result { - let id = self.open(name, None, bytes, None)?; - if let Some(d) = self.docs.iter_mut().find(|d| d.id == id) { - d.dirty = true; - if let Some(e) = d.editor.as_mut() { - e.unsaved_file = true; - } - // Unsaved work with no edit yet still needs its first recovery snapshot. - d.generation += 1; - } - Ok(id) - } - - fn cos(&self, id: DocId) -> Result<&pdfcraft_cos::Document, EditError> { - let doc = self.get(id).ok_or(EditError::NoDocument)?; - 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. - /// Like a save, a new file never carries a typed Password-field value (#3926): Extract and - /// Split copy the fields of the pages they take. - fn write_unchecked(&self, doc: &pdfcraft_cos::Document) -> Result>, EditError> { - let opts = SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), ..SaveOptions::default() }; - let doc = writable(doc)?; - write_full(&doc, &opts).map(Arc::new).map_err(|e| EditError::Write(e.to_string())) - } - - /// Whether a changed document still needs a recovery snapshot. This does not consume it. - pub fn has_pending_autosave(&self) -> bool { - self.docs.iter().any(|d| d.dirty && d.generation != d.snapshot_generation) - } - - /// Documents with unsaved changes made since the last call: their current working file, - /// for crash recovery. Encrypted documents stay encrypted in the snapshot. - pub fn autosave_snapshots(&mut self) -> Vec { - let mut out = Vec::new(); - for d in &mut self.docs { - if d.dirty && d.generation != d.snapshot_generation { - // The working bytes hold typed Password-field values (#3926, #3944): a recovery file - // is written from a copy without them, and none is written if that copy fails. - let bytes = - match d.editor.as_ref().filter(|e| pdfcraft_forms::has_password_values(&e.cos) || pdfcraft_xfa::has_password_values(&e.cos)) { - Some(e) => match writable(&e.cos) - .and_then(|c| write_incremental(&c, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))) - { - Ok(b) => Arc::new(b), - Err(_) => continue, - }, - None => d.bytes.clone(), - }; - d.snapshot_generation = d.generation; - out.push(RecoverySnapshot { - doc: d.id, - name: d.name.clone(), - path: d.path.clone(), - bytes, - encrypted: d.info.encrypted || d.editor.as_ref().is_some_and(|e| e.cos.security().is_some()), - }); - } - } - out - } - - /// Mark a document opened from a recovery file: it has unsaved changes and belongs at - /// `path` (where Save writes), as when the session ended. - pub fn mark_recovered(&mut self, id: DocId, path: Option) { - if let Some(d) = self.docs.iter_mut().find(|d| d.id == id) { - d.path = path; - d.dirty = true; - if let Some(e) = d.editor.as_mut() { - e.unsaved_file = true; - } - d.generation += 1; - d.snapshot_generation = d.generation; // its bytes are already in the recovery store - } - } - - /// Show or hide a layer (optional content group) for viewing. Returns `true` if it changed; - /// callers must drop cached rasters and text for the document. - pub fn set_layer_visible(&mut self, id: DocId, layer: usize, visible: bool) -> bool { - let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return false }; - let Some(l) = doc.info.layers.get_mut(layer) else { return false }; - if l.visible == visible { - return false; - } - l.visible = visible; - use_layer_choices(doc); - true - } - - /// Run a set-layer-visibility action (`SetOCGState`): apply `changes` in order, naming each - /// layer by its optional content group; groups that aren't layers are skipped. With - /// `preserve_rb`, a layer turned on turns off the other layers of its radio-button groups. - /// Returns `true` if any layer changed; callers must drop cached rasters and text for the - /// document, as for [`Self::set_layer_visible`]. - pub fn set_layer_state(&mut self, id: DocId, changes: &[(LayerOp, (u32, u16))], preserve_rb: bool) -> bool { - let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return false }; - if !apply_layer_state(&mut doc.info.layers, &doc.info.layer_groups, changes, preserve_rb) { - return false; - } - use_layer_choices(doc); - true - } - - /// Show each layer as the file opens (Acrobat's Layers ▸ Reset to Initial Visibility). Returns - /// `true` if any layer changed; callers must drop cached rasters and text for the document, - /// as for [`Self::set_layer_visible`]. - pub fn reset_layers(&mut self, id: DocId) -> bool { - let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return false }; - let mut changed = false; - for l in &mut doc.info.layers { - changed |= l.visible != l.initial; - l.visible = l.initial; - } - if changed { - use_layer_choices(doc); - } - changed - } - - /// Hide or show every comment on the page (Acrobat's Comments ▸ Hide all comments). Form - /// fields and links still draw. Returns whether anything changed. - pub fn set_hide_comments(&mut self, id: DocId, hide: bool) -> bool { - let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return false }; - if doc.config.hide_comments == hide { - return false; - } - doc.config.hide_comments = hide; - doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); - doc.generation += 1; - true - } - - /// Set the document's reading colours (Acrobat's Replace Document Colors). Returns whether - /// anything changed; callers must drop cached rasters and text for the document. - pub fn set_reading_colors(&mut self, id: DocId, colors: ReadingColors) -> bool { - let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return false }; - if doc.config.reading_colors == colors { - return false; - } - doc.config.reading_colors = colors; - doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); - doc.generation += 1; - true - } - - /// The document's reading-colours setting; `None` when no such document is open. - pub fn reading_colors(&self, id: DocId) -> Option { - Some(self.get(id)?.config.reading_colors) - } - - /// How the document's pages are drawn for viewing: its password and view settings (layer - /// visibility, hidden comments, reading colours). `None` when no such document is open. - pub fn render_config(&self, id: DocId) -> Option { - Some(self.get(id)?.config.clone()) - } - - /// Acrobat's Summarize Comments ("Comments only" layout): a new PDF listing every comment - /// with its number, author, type, date, text and replies, grouped by page. - pub fn summarize_comments(&self, id: DocId, sort: SummarySort) -> Result>, EditError> { - let doc = self.get(id).ok_or(EditError::NoDocument)?; - let text = comment_summary(&doc.name, &doc.info.annotations, sort); - self.create_report(&format!("Summary of Comments on {}", doc.name), &text) - } - - /// The certificates trusted for signing. - pub fn trusted_certificates(&self) -> &[pdfcraft_sign::Certificate] { - &self.trust.certs - } - - /// Replace the trusted certificates, revalidate every open document's signatures, - /// and rebuild its display copy and renderer to match (working file untouched, - /// generation/dirty unchanged: trust alone is not an edit). - pub fn set_trusted_certificates(&mut self, certs: Vec) { - self.trust = Arc::new(TrustStore { certs }); - for doc in &mut self.docs { - doc.trust = self.trust.clone(); - let Some(editor) = doc.editor.as_mut() else { continue }; - // Keep verdict refresh and renderer refresh together. Viewer caches are - // invalidated by the UI trust path; trust alone changes no document artwork. - let signatures = if doc.signatures.is_empty() { Vec::new() } else { signatures_of(&editor.cos, &doc.bytes, &doc.trust, &doc.sig_cache) }; - doc.display = display_bytes(Some(editor), &doc.bytes); - doc.signatures = Arc::new(signatures); - doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); - } - } - - /// The signing time as a PDF date in local time with its offset (`D:…+02'00'`). - pub fn signing_date(&self) -> String { - 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 { - return format!("{stamp}Z"); - } - let (sign, m) = if offset < 0 { ('-', -offset / 60) } else { ('+', offset / 60) }; - format!("{stamp}{sign}{:02}'{:02}'", m / 60, m % 60) - } - - /// Sign the document's current state with `id` (Use a certificate ▸ Digitally sign). Returns - /// the signed file; the caller saves it and then calls [`Session::mark_signed`]. An empty - /// `opts.date` takes the session clock. - pub fn sign(&self, doc: DocId, id: &pdfcraft_sign::DigitalId, mut opts: SignOptions) -> Result>, EditError> { - let d = self.get(doc).ok_or(EditError::NoDocument)?; - // Encrypted documents are signed under their permissions (the signer checks them). - let editor = d.editor.as_ref().ok_or_else(|| EditError::ReadOnly(d.read_only_reason.clone().unwrap_or_default()))?; - if opts.date.is_empty() { - opts.date = self.signing_date(); - } - // A typed Password-field value is not signed into the file (#3926), as it isn't saved. - let cos = writable(&editor.cos)?; - Ok(Arc::new(pdfcraft_sign::sign(&cos, id, &opts)?)) - } - - /// [`Session::sign`], embedding an RFC 3161 signature timestamp (PAdES B-T) produced by - /// `tsa`. The transport lives with the caller; the engine never opens a socket. - pub fn sign_with_timestamp( - &self, - doc: DocId, - id: &pdfcraft_sign::DigitalId, - mut opts: SignOptions, - tsa: &dyn pdfcraft_sign::TimestampAuthority, - ) -> 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()))?; - if opts.date.is_empty() { - opts.date = self.signing_date(); - } - let cos = writable(&editor.cos)?; - let signed = Arc::new(pdfcraft_sign::sign_with_timestamp(&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 - /// current state. An empty `date` takes the session clock; the transport is the caller's. - pub fn timestamp_document(&self, doc: DocId, tsa: &dyn pdfcraft_sign::TimestampAuthority, date: String) -> 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()))?; - let date = if date.is_empty() { self.signing_date() } else { date }; - let cos = writable(&editor.cos)?; - let stamped = Arc::new(pdfcraft_sign::timestamp_document(&cos, tsa, &date)?); - self.check_save(doc, &stamped)?; - Ok(stamped) - } - - /// Embed revocation evidence into the catalog's `/DSS` with `/VRI` entries per signature - /// (PAdES B-LT): an incremental update that never rewrites signed bytes. - 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()))?; - let cos = writable(&editor.cos)?; - let embedded = Arc::new(pdfcraft_sign::dss::embed(&cos, evidence)?); - self.check_save(doc, &embedded)?; - Ok(embedded) - } - - /// Record that the signed file `bytes` was saved (to `path`): like [`Session::mark_saved`], - /// and the edit history before signing is dropped (signing can't be undone). - pub fn mark_signed(&mut self, id: DocId, bytes: Arc>, path: Option) -> Result<(), EditError> { - self.mark_saved(id, bytes, path)?; - let doc = self.doc_mut(id)?; - if let Some(e) = doc.editor.as_mut() { - e.undo.clear(); - e.redo.clear(); - } - Ok(()) - } - - pub fn close(&mut self, id: DocId) { - self.docs.retain(|d| d.id != id); - } - - pub fn get(&self, id: DocId) -> Option<&Document> { - self.docs.iter().find(|d| d.id == id) - } - - /// Read document `id`'s font list again if content edits since the last full inspection may - /// have changed it (Document Properties ▸ Fonts, `doc_info`). A no-op otherwise. - pub fn refresh_fonts(&mut self, id: DocId) { - let Some(doc) = self.docs.iter_mut().find(|d| d.id == id && d.fonts_stale) else { return }; - if let Ok(info) = pdfcraft_render::inspect(doc.bytes.clone(), doc.password.as_deref()) { - doc.info.fonts = info.fonts; - } - doc.fonts_stale = false; - } - - pub fn docs(&self) -> &[Document] { - &self.docs - } -} diff --git a/crates/engine/src/session/editing.rs b/crates/engine/src/session/editing.rs new file mode 100644 index 0000000000..be7fd5aca5 --- /dev/null +++ b/crates/engine/src/session/editing.rs @@ -0,0 +1,556 @@ +#[cfg(test)] +use super::check_refresh_panic; +use super::{ + Arc, DocId, Document, Edit, EditCtx, EditError, Label, MAX_UNDO, ObjectKind, OnceLock, RenderPool, SaveOptions, Scope, Session, + XFA_REMOVED_PREFIX, XFA_SCRIPTS_OFF, check_permission, comment_list, display_bytes, display_config, guard, info_fields, inspect, js, keys_after, + note_warnings, render_threads, run_edit, scan_colours, scope_of, shown_under_redactions, signatures_of, sync_xfa_state, undrawn_notices, + uses_scripts, write_incremental, xfa, xfa_sync_datasets, +}; +use crate::{EditNotice, MarkKind}; + +/// Whether the edit, or any edit inside a batch, deletes pages. +fn deletes_pages(edit: &Edit) -> bool { + match edit { + Edit::DeletePages { .. } => true, + Edit::Batch { edits, .. } => edits.iter().any(deletes_pages), + _ => false, + } +} + +impl Session { + /// Apply an edit. On success the previous state is undoable and the view data is refreshed. + pub fn apply(&mut self, id: DocId, edit: Edit) -> Result<(), EditError> { + let now = self.now(); + let today = self.today(); + let js_off = self.js_off; + let xfa_time_scale = self.xfa_time_scale.unwrap_or(1); + let doc = self.doc_mut(id)?; + doc.edit_notices.clear(); + let name = doc.name.clone(); + let is_xfa = doc.info.xfa.is_some(); + let mut scope = scope_of(&edit); + let mut cx = EditCtx::new(now, doc.generation ^ (id.0 << 48)); + if !js_off { + cx.xfa = doc.xfa_template.clone(); + } + cx.xfa_time_scale = xfa_time_scale; + cx.pages = doc.info.pages.len(); + cx.today = today; + let reason = doc.read_only_reason.clone().unwrap_or_default(); + let signed = doc.is_signed(); + let editor = doc.editor.as_mut().ok_or(EditError::ReadOnly(reason))?; + if let Some(p) = editor.cos.permissions() { + check_permission(&edit, &p)?; + } + // Rewriting an OCR word (invisible text over a scan) also hides the scanned word: cover it + // in the paper's colour and draw the new text in the ink's. + let edit = match edit { + Edit::EditTextBlock { page, block, text, mut style } => { + // Only invisible text is sampled; the blocks are usually cached from opening it. + if style.cover.is_none() + && let Some(info) = doc.info.pages.get(page) + && let Some(b) = editor.text_blocks(page).ok().and_then(|b| b.get(block).filter(|b| b.invisible).cloned()) + && let Some((paper, ink)) = guard(|| scan_colours::sample(&editor.cos, info, page, &b)).ok().flatten() + { + style.cover = Some(paper); + style.color = style.color.or(Some(ink)); + } + Edit::EditTextBlock { page, block, text, style } + } + other => other, + }; + // Nothing to bake: leave undo history and the dirty flag alone. + if matches!(&edit, Edit::FlattenFillSign) && !pdfcraft_annot::has_visible_fill_sign(&editor.cos) { + return Ok(()); + } + let mut next = editor.cos.clone(); + if !js_off && uses_scripts(&edit) { + cx.js = Some(js::JsRunner::new(&next, &name)); + } + cx.renamed_fields.clear(); + cx.redaction_overlays.clear(); + // `next` is a copy: if the edit fails or crashes, the document is unchanged. + guard(|| run_edit(&mut next, &edit, &mut cx)) + .unwrap_or_else(|m| Err(EditError::Invalid(format!("{} failed unexpectedly ({m}); the document was not changed", edit.label()))))?; + // Redaction removed what its own interpreter places under the marks. The marks were drawn + // over the page as the renderer shows it, so the renderer has the last word. + if let Edit::ApplyRedactions { pages } = &edit { + let marks: Vec = + pdfcraft_redact::marks(&editor.cos).into_iter().filter(|m| pages.as_ref().is_none_or(|p| p.contains(&m.page))).collect(); + guard(|| shown_under_redactions(&next, &marks, &cx.redaction_overlays, &doc.info.pages, doc.password.as_deref())) + .unwrap_or_else(|m| Err(EditError::Invalid(format!("{} failed unexpectedly ({m}); the document was not changed", edit.label()))))?; + } + // Renamed fields are said, not slipped in: a repair-log note (with the caveat that + // scripts were left alone) and a count in the undo step's label. + let renamed_note = |renamed: &[(String, String)]| { + (!renamed.is_empty()).then(|| { + let shown: Vec = renamed.iter().take(5).map(|(old, new)| format!("{old} → {new}")).collect(); + let more = renamed.len().saturating_sub(shown.len()); + let more = if more > 0 { format!(", and {more} more") } else { String::new() }; + format!( + "inserted pages renamed {} form field{} to keep their names separate ({}{more}); scripts that refer to the old names were not rewritten", + renamed.len(), + if renamed.len() == 1 { "" } else { "s" }, + shown.join(", ") + ) + }) + }; + if let Some(note) = renamed_note(&cx.renamed_fields) { + next.note_repair(note); + } + // Only Insert from File renames fields; its label says so in a wording the interface + // translates (the count lives in the repair note). + let label = match (&edit, cx.renamed_fields.len()) { + (_, 0) => edit.label(), + (Edit::InsertPagesFrom { name, .. }, n) => Label::with( + if n == 1 { "Insert pages from {name} — 1 field renamed" } else { "Insert pages from {name} — fields renamed" }, + name.as_str(), + ), + _ => edit.label(), + }; + // An XFA form's scripts answer the change: exit and validate scripts of the field, then + // every calculation (and a reset recalculates). They read the data, so it is brought up + // to date first. + if let Some(tpl) = cx.xfa.clone() { + if is_xfa && matches!(scope_of(&edit), Scope::Form | Scope::Full | Scope::Content) { + let datasets = &mut cx.xfa_datasets; + let notes = guard(|| xfa_sync_datasets(&mut next, Some(&tpl), datasets)) + .unwrap_or_else(|m| Err(format!("writing the XFA data failed unexpectedly ({m})"))) + .map_err(EditError::Write)?; + cx.xfa_out.errors.extend(notes); + } + fn collect(e: &Edit, changed: &mut Vec, reset: &mut bool) { + match e { + Edit::SetFieldValue { name, .. } => changed.push(name.clone()), + Edit::ResetForm { .. } => *reset = true, + Edit::Batch { edits, .. } => edits.iter().for_each(|e| collect(e, changed, reset)), + _ => {} + } + } + let mut changed: Vec = Vec::new(); + let mut reset = false; + collect(&edit, &mut changed, &mut reset); + if !changed.is_empty() || reset { + let mut run = xfa::XfaRun { + page_count: cx.pages, + out: &mut cx.xfa_out, + datasets: cx.xfa_datasets, + ran_away: false, + time_scale: cx.xfa_time_scale, + }; + // Every changed field's scripts, then the calculations once (after a reset, + // only those). + let ran = guard(|| xfa::on_changes(&mut next, &tpl, &changed, &mut run)) + .unwrap_or_else(|m| Err(format!("the form's scripts failed unexpectedly ({m})"))); + cx.xfa_datasets = run.datasets; + cx.xfa_ran_away |= run.ran_away; + if let Err(e) = ran { + cx.xfa_out.errors.push(e); + } + } + } + // XFA forms keep their values in the datasets packet too, for Adobe's viewers. + let mut xfa_notes = Vec::new(); + if is_xfa && matches!(scope_of(&edit), Scope::Form | Scope::Full | Scope::Content) { + let (tpl, datasets) = (cx.xfa.as_deref(), &mut cx.xfa_datasets); + xfa_notes = guard(|| xfa_sync_datasets(&mut next, tpl, datasets)) + .unwrap_or_else(|m| Err(format!("writing the XFA data failed unexpectedly ({m})"))) + .map_err(EditError::Write)?; + // Rebuilding from stale data would discard accepted widget values. + if xfa_notes.is_empty() { + let reflow = guard(|| xfa::relayout_changed_text(&mut next, tpl, &doc.form)) + .unwrap_or_else(|m| Err(format!("laying out the XFA text failed unexpectedly ({m})"))) + .map_err(EditError::Write)?; + if let Some(warnings) = reflow { + xfa_notes.extend(warnings); + // Layout replaces pages and widgets, even if the page count is unchanged. + // Undo/redo must rebuild these too instead of refreshing only field values. + scope = Scope::Full; + } + } + } + // Signed documents are only ever saved incrementally: an edit that needs a full rewrite + // (applying redactions, changing security, sanitizing) would invalidate the signatures. + // So does any edit to a file rebuilt from object headers: it has no chain to append to. + // Deleted pages remain in earlier incremental revisions (#3407), so rewrite + // unsigned files in full. Signed files must retain their signed bytes. + if !signed && deletes_pages(&edit) { + next.require_full_save(); + } + let rewrites = |c: &pdfcraft_cos::Document| c.full_save_required() || c.encryption_changed(); + if signed && ((rewrites(&next) && !rewrites(&editor.cos)) || (next.is_modified() && next.revisions().is_empty())) { + return Err(EditError::SignedRewrite(edit.label().to_string())); + } + let notices = edit_notices(&edit, &editor.cos, &next); + let previous = std::mem::replace(&mut editor.cos, next); + let keys = keys_after(&edit).unwrap_or_else(|| editor.keys.clone()); + let previous_keys = std::mem::replace(&mut editor.keys, keys); + editor.undo.push((label, previous, previous_keys, scope)); + if editor.undo.len() > MAX_UNDO { + editor.undo.remove(0); + } + editor.redo.clear(); + Self::adopt_keys(doc); + let refreshed = guard(|| if scope == Scope::Content { Self::refresh_content(doc, Some(&edit)) } else { Self::refresh_scoped(doc, scope) }) + .unwrap_or_else(|m| Err(EditError::Reopen(m))); + if let Err(e) = refreshed { + // Roll back: the edit produced something we cannot display. + if let Some(ed) = doc.editor.as_mut() + && let Some((_, prev, keys, _)) = ed.undo.pop() + { + ed.cos = prev; + ed.keys = keys; + } + Self::adopt_keys(doc); + if !matches!(guard(|| Self::refresh(doc)), Ok(Ok(()))) { + Self::show_released(doc); + } + return Err(e); + } + 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); + if let Some(js) = cx.js { + doc.js_output.append(js.output); + } + doc.js_output.append(cx.xfa_out); + if cx.xfa_ran_away { + doc.xfa_template = None; + doc.xfa_scripts_off = true; + doc.js_output.errors.push(XFA_SCRIPTS_OFF.into()); + } + Ok(()) + } + + pub fn undo(&mut self, id: DocId) -> Result { + let doc = self.doc_mut(id)?; + let editor = doc.editor.as_mut().ok_or(EditError::NothingToUndo)?; + let (label, prev, keys, scope) = editor.undo.pop().ok_or(EditError::NothingToUndo)?; + let current = std::mem::replace(&mut editor.cos, prev); + let current_keys = std::mem::replace(&mut editor.keys, keys); + editor.redo.push((label.clone(), current, current_keys, scope)); + Self::adopt_keys(doc); + let refreshed = guard(|| Self::refresh_scoped(doc, scope)).unwrap_or_else(|m| Err(EditError::Reopen(m))); + if let Err(e) = refreshed { + if let Some(ed) = doc.editor.as_mut() + && let Some((label, current, current_keys, scope)) = ed.redo.pop() + { + let prev = std::mem::replace(&mut ed.cos, current); + let keys = std::mem::replace(&mut ed.keys, current_keys); + ed.undo.push((label, prev, keys, scope)); + } + Self::adopt_keys(doc); + if !matches!(guard(|| Self::refresh(doc)), Ok(Ok(()))) { + Self::show_released(doc); + } + return Err(e); + } + doc.edit_notices.clear(); + Self::sync_dirty(doc); + doc.generation += 1; + Ok(label) + } + + pub fn redo(&mut self, id: DocId) -> Result { + let doc = self.doc_mut(id)?; + let editor = doc.editor.as_mut().ok_or(EditError::NothingToRedo)?; + let (label, next, keys, scope) = editor.redo.pop().ok_or(EditError::NothingToRedo)?; + let current = std::mem::replace(&mut editor.cos, next); + let current_keys = std::mem::replace(&mut editor.keys, keys); + editor.undo.push((label.clone(), current, current_keys, scope)); + Self::adopt_keys(doc); + let refreshed = guard(|| Self::refresh_scoped(doc, scope)).unwrap_or_else(|m| Err(EditError::Reopen(m))); + if let Err(e) = refreshed { + if let Some(ed) = doc.editor.as_mut() + && let Some((label, current, current_keys, scope)) = ed.undo.pop() + { + let next = std::mem::replace(&mut ed.cos, current); + let keys = std::mem::replace(&mut ed.keys, current_keys); + ed.redo.push((label, next, keys, scope)); + } + Self::adopt_keys(doc); + if !matches!(guard(|| Self::refresh(doc)), Ok(Ok(()))) { + Self::show_released(doc); + } + return Err(e); + } + doc.edit_notices.clear(); + Self::sync_dirty(doc); + doc.generation += 1; + Ok(label) + } + + /// Unsaved when the working graph is not the last opened or saved file. Undo back to that + /// file is clean; undo past a later save is not, because the restored snapshot's own base + /// is an earlier revision. + pub(super) fn sync_dirty(doc: &mut Document) { + doc.dirty = doc.editor.as_ref().is_some_and(|e| e.unsaved_file || e.cos.is_modified() || !Arc::ptr_eq(e.cos.bytes(), &e.saved)); + } + + /// Use the current state's password for viewing (protection edits change it). + pub(super) fn adopt_keys(doc: &mut Document) { + if let Some(e) = doc.editor.as_ref() { + doc.password = e.keys.render.clone(); + doc.config.password = e.keys.render.as_deref().map(Arc::from); + } + } + + /// Rebuild the working bytes and the view data that `scope` may have changed. Metadata edits + /// keep the renderer; other scopes also replace it. + /// Comment and form edits skip the full re-inspection (seconds on very large files): the + /// comment list and field values are re-read from the object graph instead. + fn refresh_scoped(doc: &mut Document, scope: Scope) -> Result<(), EditError> { + if scope == Scope::Full || doc.info.encrypted != doc.editor.as_ref().is_some_and(|e| e.cos.output_handler().is_some()) { + return Self::refresh(doc); + } + if scope == Scope::Content { + return Self::refresh_content(doc, None); + } + if scope == Scope::Links { + return Self::refresh_links(doc); + } + // Metadata edits keep the renderer, which holds the working file anyway. + if scope != Scope::Metadata { + Self::release_working(doc); + } + let Some(editor) = doc.editor.as_mut() else { return Ok(()) }; + // Comment edits, and form edits (a script may change annotation properties), may change + // annotation dictionaries, invalidating cached appearances. + if matches!(scope, Scope::Comments | Scope::Form) { + editor.appearances.clear(); + } + let bytes = if editor.cos.is_modified() { + Arc::new(write_incremental(&editor.cos, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))?) + } else { + editor.cos.bytes().clone() + }; + if scope == Scope::Metadata { + for (field, value) in info_fields(&mut doc.info, &editor.cos) { + *field = value; + } + doc.info.custom = pdfcraft_organize::custom_info(&editor.cos); + doc.info.file_size = bytes.len(); + #[cfg(test)] + check_refresh_panic(); + if !doc.signatures.is_empty() { + let signatures = signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache); + doc.signatures = Arc::new(signatures); + } + doc.bytes = bytes; + return Ok(()); + } + let mut form = pdfcraft_forms::fields(&editor.cos); + xfa::mark_script_buttons(&editor.cos, &mut form); + let form = Arc::new(form); + match scope { + Scope::Metadata => return Ok(()), + Scope::Comments => { + doc.info.annotations = comment_list(&editor.cos); + // Link indexes are positions in /Annots, so comment insertion/deletion can move them. + doc.links = OnceLock::new(); + } + Scope::Form => { + // PERF: keystroke path, index once. A linear find per field is O(F^2); + // forms reach 10k fields. + let mut by_name = std::collections::HashMap::with_capacity(form.len()); + for x in form.iter() { + // First match wins (do not simplify to collect: it keeps the last). + by_name.entry(x.name.as_str()).or_insert(x); + } + for f in &mut doc.info.fields { + if let Some(ff) = by_name.get(f.name.as_str()) { + f.value = match ff.kind { + pdfcraft_forms::FieldKind::CheckBox | pdfcraft_forms::FieldKind::Radio => { + Some(ff.value.first().cloned().unwrap_or_else(|| "Off".into())) + } + _ if ff.value.is_empty() => None, + _ => Some(ff.value.join(", ")), + }; + } + } + } + Scope::Links => return Self::refresh_links(doc), + // Handled above; kept as a full refresh so the match stays total. + Scope::Full => return Self::refresh(doc), + // Handled above (content-only refresh); the arm keeps the match total. + Scope::Content => return Self::refresh_content(doc, None), + } + doc.info.file_size = bytes.len(); + doc.form = form; + let signatures = if doc.signatures.is_empty() { Vec::new() } else { signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache) }; + doc.display = display_bytes(Some(editor), &bytes); + doc.signatures = Arc::new(signatures); + doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config)); + doc.bytes = bytes; + Ok(()) + } + + /// Which derived lists a content-only edit can change: added content (`None`, from + /// undo/redo, re-reads both: always correct, rarely paid). + pub(crate) fn content_refresh_lists(edit: Option<&Edit>) -> (bool, bool) { + if let Some(Edit::Batch { edits, .. }) = edit { + // A batch of content edits (from a script, say) changes what its parts change. + return edits.iter().fold((false, false), |(added, marks), part| { + let (a, m) = Self::content_refresh_lists(Some(part)); + (added || a, marks || m) + }); + } + let touches_added = edit.is_none_or(|e| { + matches!( + e, + Edit::AddText { .. } + | Edit::AddImage { .. } + | Edit::UpdateContent { .. } + | Edit::DeleteContent { .. } + | Edit::ReplaceImage { .. } + | Edit::PasteContent { .. } + | Edit::EditPageImage { .. } + ) || matches!(e, Edit::MoveObjects { objects, .. } if objects.iter().any(|o| o.kind == ObjectKind::Added)) + }); + let touches_marks = edit.is_none_or(|e| { + matches!(e, Edit::AddHeaderFooter { .. } | Edit::AddWatermark { .. } | Edit::AddBackground { .. } | Edit::RemoveMarks { .. }) + }); + (touches_added, touches_marks) + } + + /// Rebuild working bytes, display bytes and renderer after a content-only edit: page + /// content streams, resources and added content/marks. The page tree, annotations, form + /// fields, links, outline and fonts are unchanged, so the full re-inspection (seconds on + /// very large files) is skipped. `edit` selects which derived lists are re-read (`None`, + /// from undo/redo, re-reads both: always correct, rarely paid). The cached display + /// 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(()) }; + let bytes = if editor.cos.is_modified() { + Arc::new(write_incremental(&editor.cos, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))?) + } else { + editor.cos.bytes().clone() + }; + // The edit must still leave a file the viewer can open; anything else rolls back above. + pdfcraft_render::check_pages(bytes.clone(), doc.password.as_deref(), doc.info.pages.len()).map_err(|e| EditError::Reopen(e.to_string()))?; + let (touches_added, touches_marks) = Self::content_refresh_lists(edit); + if touches_added || touches_marks { + doc.marker_scan = OnceLock::new(); + } + doc.info.file_size = bytes.len(); + let signatures = if doc.signatures.is_empty() { Vec::new() } else { signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache) }; + doc.display = display_bytes(Some(editor), &bytes); + doc.signatures = Arc::new(signatures); + doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config)); + doc.bytes = bytes; + doc.fonts_stale = true; + Ok(()) + } + + /// Let go of the working file before the next one is written (gap 13). The working file is + /// the original plus one appended revision, so on a 1 GiB file keeping the old one (in the + /// working bytes, the display bytes and the renderer) while writing the new one held three + /// copies at once; this keeps two. Until the refresh finishes, the working and display bytes + /// are the original's (already held by the editor, so free) and the renderer draws nothing. + /// A failed edit rolls back and refreshes again, which replaces all three; where a refresh + /// still fails, [`Self::show_released`] draws the original rather than nothing. Save writes + /// from the editor, never from these. + pub(crate) fn release_working(doc: &mut Document) { + let Some(original) = doc.editor.as_ref().map(|e| e.cos.bytes().clone()) else { return }; + // Dropping the pool stops its workers; each lets go of the file when it exits. + doc.renderer = RenderPool::new_inline(Arc::new(Vec::new()), doc.config.clone()); + doc.display = original.clone(); + doc.bytes = original; + #[cfg(test)] + check_refresh_panic(); + } + + /// After a refresh failed: draw the display bytes it left (the original, once released), + /// so the page still shows a document. + pub(super) fn show_released(doc: &mut Document) { + doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config)); + } + + /// Rebuild working bytes, inspection and renderer from the current edit state. + pub(crate) fn refresh(doc: &mut Document) -> Result<(), EditError> { + Self::refresh_with_indexes(doc, true, true) + } + + /// Reinspect a link edit while preserving the page-content marker scan cache. + fn refresh_links(doc: &mut Document) -> Result<(), EditError> { + Self::refresh_with_indexes(doc, false, true) + } + + fn refresh_with_indexes(doc: &mut Document, invalidate_markers: bool, invalidate_links: bool) -> Result<(), EditError> { + // A full refresh may follow structural or annotation changes: cached appearances + // may be stale, so they are rebuilt below. + if let Some(editor) = doc.editor.as_mut() { + editor.appearances.clear(); + } + Self::release_working(doc); + sync_xfa_state(doc); + let Some(editor) = doc.editor.as_mut() else { return Ok(()) }; + let bytes = if editor.cos.is_modified() { + Arc::new(write_incremental(&editor.cos, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))?) + } else { + editor.cos.bytes().clone() + }; + let info = inspect(bytes.clone(), doc.password.as_deref()).map_err(|e| EditError::Reopen(e.to_string()))?; + // Keep the user's layer choices where the layers still exist. + let mut info = info; + for l in &mut info.layers { + if let Some(old) = doc.info.layers.iter().find(|o| o.id == l.id) { + l.visible = old.visible; + } + } + note_warnings(&mut info.warnings, &doc.xfa_warnings); + // As in `build_document`: the inspection reads only `/Info`, so fields stated only in XMP + // are filled in again after every full refresh. + for (field, value) in info_fields(&mut info, &editor.cos) { + if field.is_none() { + *field = value; + } + } + doc.info = info; + doc.fonts_stale = false; + let mut form = pdfcraft_forms::fields(&editor.cos); + xfa::mark_script_buttons(&editor.cos, &mut form); + doc.form = Arc::new(form); + if invalidate_markers { + doc.marker_scan = OnceLock::new(); + } + if invalidate_links { + doc.links = OnceLock::new(); + } + let signatures = signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache); + doc.display = display_bytes(Some(editor), &bytes); + doc.signatures = Arc::new(signatures); + doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config)); + doc.bytes = bytes; + Ok(()) + } +} + +/// What `edit`, taking `prev` to `next`, wants said: never silent (AGENTS.md §4). +fn edit_notices(edit: &Edit, prev: &pdfcraft_cos::Document, next: &pdfcraft_cos::Document) -> Vec { + // What it drew with `?` (a value or comment text its font can't draw, stored as typed). + let mut notices: Vec = if matches!(scope_of(edit), Scope::Form | Scope::Comments | Scope::Full) { + guard(|| undrawn_notices(prev, next)).unwrap_or_default().into_iter().map(EditNotice::Undrawn).collect() + } else { + Vec::new() + }; + // Pages whose own white backdrop Remove background couldn't turn back on. + if matches!(edit, Edit::RemoveMarks { kind: MarkKind::Background }) { + let pages = guard(|| pdfcraft_edit::backdrops_left_off(next)).unwrap_or_default(); + if !pages.is_empty() { + notices.push(EditNotice::BackdropsLeftOff { pages }); + } + } + notices +} diff --git a/crates/engine/src/session/files.rs b/crates/engine/src/session/files.rs new file mode 100644 index 0000000000..553ddd6aa3 --- /dev/null +++ b/crates/engine/src/session/files.rs @@ -0,0 +1,403 @@ +use super::{ + Arc, CombineSource, Combined, DataFormat, DocId, EditError, Hidden, ImageResolution, LOW_RESOLUTION_PRINT_DPI, OptimizeReport, SaveOptions, + Session, SourceKind, SplitPart, decode_text, guard, open_source, open_source_with, optimize, print, print_raster, set_default_layer_state, + source_kind, writable, write_full, +}; + +impl Session { + /// The page count of another PDF (Replace Pages, Insert Pages dialogs). + pub fn page_count_of(&self, name: &str, bytes: &Arc>) -> Result { + Ok(pdfcraft_organize::page_count(&open_source(name, bytes)?)?) + } + + /// A new blank document (Create ▸ Blank page). + pub fn create_blank(&self, width: f64, height: f64, pages: usize) -> Result>, EditError> { + self.write_new(&pdfcraft_create::blank(width, height, pages)?) + } + + /// A new document with one page per image (PNG, JPEG). + pub fn create_from_images(&self, images: &[(String, Vec)]) -> Result>, EditError> { + self.write_new(&pdfcraft_create::from_images(images)?) + } + + /// Create image pages at embedded resolution or a fixed dpi, without resampling. + pub fn create_from_images_with_resolution(&self, images: &[(String, Vec)], resolution: ImageResolution) -> Result>, EditError> { + self.write_new(&pdfcraft_create::from_images_with_resolution(images, resolution)?) + } + + /// A new document from plain text (US Letter, 11 pt Helvetica). Text Helvetica can't draw is + /// refused, naming the line and the character (never set as `?`). + pub fn create_from_text(&self, title: &str, text: &str) -> Result>, EditError> { + self.write_new(&pdfcraft_create::from_text(title, text, pdfcraft_create::LETTER, 11.0)?) + } + + /// [`Self::create_from_text`] for a report PdfCraft writes from documents (comment summaries, + /// compare reports), which can quote any language: characters Helvetica can't draw are written + /// as `[U+XXXX]`, and the report's first line says so, rather than the report being refused. + pub(crate) fn create_report(&self, title: &str, text: &str) -> Result>, EditError> { + let (doc, _escaped) = pdfcraft_create::from_text_escaped(title, text, pdfcraft_create::LETTER, 11.0)?; + self.write_new(&doc) + } + + /// Convert a file Create understands (an image or plain text) to PDF bytes; a PDF is checked + /// (it must open and allow copying pages) and returned as it is. + pub fn convert_to_pdf(&self, name: &str, bytes: &Arc>) -> Result<(SourceKind, Arc>), EditError> { + let Some(kind) = source_kind(name, bytes) else { + return Err(EditError::Source(format!("{name}: this file type can't be converted; use a PDF, an image or a .txt file"))); + }; + let title = name.rsplit_once('.').map_or(name, |(s, _)| s); + // Image decoders read untrusted bytes: a panic in one must not take the app down. + let created = guard(|| match kind { + SourceKind::Pdf => open_source(name, bytes).map(|_| bytes.clone()), + SourceKind::Image => self.create_from_images(&[(name.to_string(), bytes.to_vec())]), + SourceKind::Text => self.create_from_text(title, &decode_text(bytes)), + }) + .map_err(|_| EditError::Source(format!("{name}: the file could not be read")))?; + Ok((kind, created?)) + } + + /// A file for Combine as PDF bytes: an image or text file is converted; a PDF is returned as + /// it is, unchecked (Combine reads it itself, with its password), as is a file named .pdf + /// without a `%PDF-` header, which the reader may still open. + pub fn as_pdf(&self, name: &str, bytes: &Arc>) -> Result>, EditError> { + let pdf = match source_kind(name, bytes) { + Some(SourceKind::Pdf) => true, + Some(SourceKind::Image | SourceKind::Text) => false, + None => name.to_ascii_lowercase().ends_with(".pdf"), + }; + if pdf { Ok(bytes.clone()) } else { self.convert_to_pdf(name, bytes).map(|(_, pdf)| pdf) } + } + + /// Reduce File Size: Acrobat's defaults (colour and grey images above 225 ppi to 150 ppi, + /// JPEG medium quality; black-and-white averaged down to 300 ppi above 450 ppi, and stored as + /// CCITT Group 4 whenever smaller; thumbnails dropped), identical resources merged, unused + /// objects dropped, objects packed into compressed object streams. Returns the bytes, how + /// many objects were merged and how many images stayed as they were, past what one run may + /// decode. The open document is not changed (Acrobat saves the reduced copy as a new file). + pub fn reduced_bytes(&self, id: DocId) -> Result<(Arc>, usize, usize), EditError> { + let (bytes, report) = self.optimized_bytes(id, &optimize::Settings::default(), &[])?; + Ok((bytes, report.merged, report.optimize.images_refused)) + } + + /// Optimize PDF ▸ Advanced optimization: `settings` for images and objects, plus Remove + /// Hidden Information's `discard` categories (user data). A full rewrite: signed documents + /// are refused. The open document is not changed. This is [`Self::optimize_job`] run in + /// place, without progress. + pub fn optimized_bytes(&self, id: DocId, settings: &optimize::Settings, discard: &[Hidden]) -> Result<(Arc>, OptimizeReport), EditError> { + self.optimize_job(id, settings, discard)?.run(|_| true) + } + + /// Export comments and/or form data: XFDF and FDF carry either or both; XML, CSV and text + /// are form data only. + pub fn export_data(&self, id: DocId, format: DataFormat, comments: bool, fields: bool) -> Result, EditError> { + let doc = self.get(id).ok_or(EditError::NoDocument)?; + let cos = match doc.editor.as_ref() { + Some(e) => e.cos.clone(), + None => { + pdfcraft_cos::Document::open_with_password(doc.bytes.clone(), doc.password.as_deref()).map_err(|e| EditError::Write(e.to_string()))? + } + }; + // An XFA form's data is its datasets, as Acrobat exports it: XML or an XDP. + let xfa = doc.info.xfa.is_some(); + if format == DataFormat::Xdp && !xfa { + return Err(EditError::Invalid("XDP holds XFA form data; this document is not an XFA form (use .xml, .csv or .txt)".into())); + } + guard(|| match format { + DataFormat::Xfdf => Ok(pdfcraft_xfdf::export_xfdf(&cos, comments, fields, &doc.name).into_bytes()), + DataFormat::Fdf => pdfcraft_xfdf::export_fdf(&cos, comments, fields, &doc.name).map_err(|e| e.to_string()), + DataFormat::Xml | DataFormat::Xdp if xfa => { + pdfcraft_xfa::export_data(&cos, format == DataFormat::Xdp, &doc.name).map(String::into_bytes).map_err(|e| e.to_string()) + } + other => Ok(pdfcraft_xfdf::export_data(&cos, other).into_bytes()), + }) + .map_err(EditError::Write)? + .map_err(EditError::Write) + } + + /// The print-ready PDF for `settings` (sheets laid out for the paper; see `pdfcraft-print`). + pub fn print_pdf(&self, id: DocId, settings: &print::Settings) -> Result, EditError> { + let doc = self.get(id).ok_or(EditError::NoDocument)?; + if !doc.allows_printing() { + return Err(EditError::NotPermitted("printing")); + } + // Table 22, bit 12 clear: only a low-level representation may be printed, so the pages + // always go as pictures, at no more than the low resolution. + let low_res = doc.permissions().is_some_and(|p| !p.print_high_quality()).then(|| print::Raster { + dpi: settings.raster.map_or(LOW_RESOLUTION_PRINT_DPI, |r| r.dpi.min(LOW_RESOLUTION_PRINT_DPI)), + grayscale: settings.raster.is_some_and(|r| r.grayscale), + }); + // 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) = low_res.as_ref().or(settings.raster.as_ref()) { + let mut distinct: Vec = Vec::new(); + for &p in &settings.pages { + if !distinct.contains(&p) { + distinct.push(p); + } + } + let bytes = print_raster::rasterize(doc, &distinct, raster, settings.content).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 mut cos = match doc.editor.as_ref() { + Some(e) => e.cos.clone(), + None => { + pdfcraft_cos::Document::open_with_password(doc.bytes.clone(), doc.password.as_deref()).map_err(|e| EditError::Write(e.to_string()))? + } + }; + // #3046: the layers print as they are shown. Once the Layers panel (or a set-layer + // action) has changed one, the print copy's default configuration says which are on, so a + // hidden layer stays off on paper too; untouched, the document's own configuration prints. + if !doc.config.layers.is_empty() { + set_default_layer_state(&mut cos, &doc.info.layers); + } + pdfcraft_print::impose(&cos, settings).map_err(|e| EditError::Print(e.to_string())) + } + + /// For each page, whether its text mostly runs bottom-to-top ([`pdfcraft_print::text_reads_up`]); + /// only `pages` are read, the others are `false`. + pub fn print_text_up(&self, id: DocId, pages: &[usize]) -> Vec { + let Some(doc) = self.get(id) else { return Vec::new() }; + let cos = match doc.editor.as_ref() { + Some(e) => e.cos.clone(), + None => match pdfcraft_cos::Document::open_with_password(doc.bytes.clone(), doc.password.as_deref()) { + Ok(c) => c, + Err(_) => return Vec::new(), + }, + }; + (0..doc.info.pages.len()).map(|i| pages.contains(&i) && pdfcraft_print::text_reads_up(&cos, i)).collect() + } + + /// Combine whole files, in order, into new PDF bytes (one bookmark per file). + pub fn combine(&self, sources: &[(String, Arc>)]) -> Result>, EditError> { + let docs = sources.iter().map(|(n, b)| open_source(n, b)).collect::, _>>()?; + let named: Vec<(&str, &pdfcraft_cos::Document)> = sources.iter().map(|(n, _)| n.as_str()).zip(docs.iter()).collect(); + let out = pdfcraft_organize::combine(&named)?; + self.write_new(&out) + } + + /// Combine Files with a page range per file ("1-3, 6"; `None` or empty for all pages). + pub fn combine_ranges(&self, sources: &[CombineSource]) -> Result>, EditError> { + self.combine_unlocked(sources, &[]) + } + + /// Combine sources one at a time, in order (Create ▸ Multiple files), to the same result as + /// [`Self::combine_ranges`]. Each item is a source's bookmark title, its PDF bytes (see + /// [`Self::convert_to_pdf`]) and its page range (`None` for all pages). Items are pulled one at a + /// time: a source is opened, its pages are copied into the result, and its bytes are released + /// before the next item is read. So the number of sources is not limited, and one source's + /// document is held at a time. An item's error stops the combine and is returned as it is. + /// `Ok(None)` when there are no sources. + pub fn combine_each(&self, sources: I) -> Result>>, E> + where + I: IntoIterator>, + E: From, + { + let mut combiner = pdfcraft_organize::Combiner::new(); + let mut added = false; + for source in sources { + let (title, bytes, range) = source?; + let doc = open_source(&title, &bytes)?; + let range = range.as_deref().map(str::trim).filter(|r| !r.is_empty()); + let pages = match range { + Some(r) => { + let n = pdfcraft_organize::page_count(&doc).map_err(EditError::from)?; + let p = pdfcraft_print::select_pages(n, Some(r), &[], pdfcraft_print::Subset::All, false) + .map_err(|e| EditError::Print(format!("{title}: {e}")))?; + Some(p) + } + None => None, + }; + combiner.add(&title, &doc, pages.as_deref()).map_err(EditError::from)?; + added = true; + } + if !added { + return Ok(None); + } + let out = combiner.finish().map_err(EditError::from)?; + Ok(Some(self.write_new(&out)?)) + } + + /// [`Self::combine_ranges`] with the password each encrypted source is opened with, by + /// position (missing or `None`: no password). The result is not encrypted. + pub fn combine_unlocked(&self, sources: &[CombineSource], passwords: &[Option<&str>]) -> Result>, EditError> { + Ok(self.combine_unlocked_reporting(sources, passwords, pdfcraft_organize::FieldConflicts::Rename)?.bytes) + } + + /// The pre-flight over byte sources: what combining them, in order, would rename in each one + /// (whole files; page ranges are the combine's to apply). A source that cannot be opened + /// previews as nothing — its row already says why it cannot be combined. + pub fn combine_field_conflicts(&self, sources: &[CombineSource], passwords: &[Option<&str>]) -> Vec> { + let docs: Vec> = + sources.iter().enumerate().map(|(i, (n, b, _))| open_source_with(n, b, passwords.get(i).copied().flatten()).ok()).collect(); + let refs: Vec<&pdfcraft_cos::Document> = docs.iter().filter_map(Option::as_ref).collect(); + let mut opened = pdfcraft_organize::combine_field_conflicts(&refs).into_iter(); + // One preview per source, in step with it: a file that cannot be opened has none. + docs.iter().map(|d| if d.is_some() { opened.next().unwrap_or_default() } else { Default::default() }).collect() + } + + /// [`Self::combine_unlocked`] reporting what the conflict resolution renamed. + pub fn combine_unlocked_reporting( + &self, + sources: &[CombineSource], + passwords: &[Option<&str>], + field_conflicts: pdfcraft_organize::FieldConflicts, + ) -> Result { + let docs = sources + .iter() + .enumerate() + .map(|(i, (n, b, _))| open_source_with(n, b, passwords.get(i).copied().flatten())) + .collect::, _>>()?; + let mut pages = Vec::with_capacity(docs.len()); + for ((name, _, range), d) in sources.iter().zip(&docs) { + let range = range.as_deref().map(str::trim).filter(|r| !r.is_empty()); + pages.push(match range { + Some(r) => { + let n = pdfcraft_organize::page_count(d)?; + let p = pdfcraft_print::select_pages(n, Some(r), &[], pdfcraft_print::Subset::All, false) + .map_err(|e| EditError::Print(format!("{name}: {e}")))?; + Some(p) + } + None => None, + }); + } + let named: Vec<(&str, &pdfcraft_cos::Document, Option<&[usize]>)> = + sources.iter().zip(docs.iter()).zip(&pages).map(|(((n, _, _), d), p)| (n.as_str(), d, p.as_deref())).collect(); + let out = pdfcraft_organize::combine_selected_with(&named, field_conflicts)?; + let bytes = self.write_new(&out.doc)?; + Ok(Combined { bytes, renamed_fields: out.renamed_fields }) + } + + /// [`Self::combine_unlocked`] where sources with the same `groups` key (by position) are one + /// file split into several runs placed apart: each file is opened and copied once, so it keeps + /// one bookmark, its links between its own pages, its fields and its attachments whole (see + /// `pdfcraft_organize::combine_grouped`). A group's later sources use its first source's bytes + /// and password; a source without a key is a file of its own. + pub fn combine_grouped(&self, sources: &[CombineSource], groups: &[u64], passwords: &[Option<&str>]) -> Result>, EditError> { + Ok(self.combine_grouped_reporting(sources, groups, passwords, pdfcraft_organize::FieldConflicts::Rename)?.bytes) + } + + /// [`Self::combine_grouped`] reporting what the conflict resolution renamed. + pub fn combine_grouped_reporting( + &self, + sources: &[CombineSource], + groups: &[u64], + passwords: &[Option<&str>], + field_conflicts: pdfcraft_organize::FieldConflicts, + ) -> Result { + let (mut keys, mut docs, mut file): (Vec>, Vec, Vec) = (Vec::new(), Vec::new(), Vec::new()); + for (i, (name, bytes, _)) in sources.iter().enumerate() { + let key = groups.get(i).copied(); + let known = key.and_then(|k| keys.iter().position(|g| *g == Some(k))); + let at = match known { + Some(at) => at, + None => { + docs.push(open_source_with(name, bytes, passwords.get(i).copied().flatten())?); + keys.push(key); + docs.len() - 1 + } + }; + file.push(at); + } + let mut pages = Vec::with_capacity(sources.len()); + for ((name, _, range), at) in sources.iter().zip(&file) { + let range = range.as_deref().map(str::trim).filter(|r| !r.is_empty()); + let Some(d) = docs.get(*at) else { return Err(EditError::Write(format!("{name}: not opened"))) }; + pages.push(match range { + Some(r) => { + let n = pdfcraft_organize::page_count(d)?; + let p = pdfcraft_print::select_pages(n, Some(r), &[], pdfcraft_print::Subset::All, false) + .map_err(|e| EditError::Print(format!("{name}: {e}")))?; + Some(p) + } + None => None, + }); + } + let mut runs: Vec> = Vec::with_capacity(sources.len()); + for (((name, _, _), at), p) in sources.iter().zip(&file).zip(&pages) { + let Some(d) = docs.get(*at) else { return Err(EditError::Write(format!("{name}: not opened"))) }; + runs.push((*at, name.as_str(), d, p.as_deref())); + } + let out = pdfcraft_organize::combine_grouped_with(&runs, field_conflicts)?; + let bytes = self.write_new(&out.doc)?; + Ok(Combined { bytes, renamed_fields: out.renamed_fields }) + } + + /// New PDF bytes containing copies of `pages` of the document (Extract Pages). + pub fn extract(&self, id: DocId, pages: &[usize]) -> Result>, EditError> { + let src = self.cos(id)?; + if src.permissions().is_some_and(|p| !p.assemble()) { + return Err(EditError::NotPermitted("extracting pages")); + } + let out = pdfcraft_organize::extract_pages(src, pages)?; + self.write_new(&out) + } + + /// Split the document into several new PDFs. + pub fn split(&self, id: DocId, by: &pdfcraft_organize::SplitBy) -> Result, EditError> { + let src = self.cos(id)?; + if src.permissions().is_some_and(|p| !p.assemble()) { + return Err(EditError::NotPermitted("splitting the document")); + } + let n = pdfcraft_organize::page_count(src)?; + pdfcraft_organize::split_ranges(n, by) + .into_iter() + .map(|r| { + let doc = pdfcraft_organize::extract_pages(src, &r.clone().collect::>())?; + Ok((r.start + 1, r.end, self.write_new(&doc)?)) + }) + .collect() + } + + /// Split into parts of at most `max_bytes` each (Acrobat's "File size"): pages are taken in + /// order while their size (as single-page files, an over-estimate because shared fonts and + /// images count once per part) fits; a page larger than the limit is a part of its own. + pub fn split_by_size(&self, id: DocId, max_bytes: usize) -> Result, EditError> { + let src = self.cos(id)?; + if src.permissions().is_some_and(|p| !p.assemble()) { + return Err(EditError::NotPermitted("splitting the document")); + } + let n = pdfcraft_organize::page_count(src)?; + let mut cuts = Vec::new(); + let mut used = 0usize; + for p in 0..n { + let size = self.write_unchecked(&pdfcraft_organize::extract_pages(src, &[p])?)?.len(); + if used > 0 && used + size > max_bytes { + cuts.push(p); + used = 0; + } + used += size; + } + self.split(id, &pdfcraft_organize::SplitBy::Before(cuts)) + } + + fn cos(&self, id: DocId) -> Result<&pdfcraft_cos::Document, EditError> { + let doc = self.get(id).ok_or(EditError::NoDocument)?; + 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. + /// Like a save, a new file never carries a typed Password-field value (#3926): Extract and + /// Split copy the fields of the pages they take. + fn write_unchecked(&self, doc: &pdfcraft_cos::Document) -> Result>, EditError> { + let opts = SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), ..SaveOptions::default() }; + let doc = writable(doc)?; + write_full(&doc, &opts).map(Arc::new).map_err(|e| EditError::Write(e.to_string())) + } +} diff --git a/crates/engine/src/session/save.rs b/crates/engine/src/session/save.rs new file mode 100644 index 0000000000..1e6918f6a6 --- /dev/null +++ b/crates/engine/src/session/save.rs @@ -0,0 +1,338 @@ +use super::{ + Arc, DocId, EditError, Keys, OpenError, RecoverySnapshot, RenderPool, SaveOptions, Session, SignOptions, TrustStore, display_bytes, + display_config, guard, render_threads, signatures_of, writable, write_full, write_incremental, xfa_sync_datasets, +}; + +impl Session { + /// The bytes to write for Save: an incremental update of the file as opened/last saved, + /// with `/ModDate` stamped. Call `mark_saved` after writing them successfully. + /// + /// A file whose cross-reference data had to be rebuilt is written in full even when nothing + /// was edited: its bytes as opened are the damaged file, and this save is how the repair the + /// user was told about reaches the disk (#1937). Not a signed one: a full rewrite would + /// break its signatures, so with no edits it keeps its bytes, damage and all. + pub fn save_bytes(&self, id: DocId) -> Result>, EditError> { + let doc = self.get(id).ok_or(EditError::NoDocument)?; + let Some(editor) = doc.editor.as_ref() else { return Ok(doc.bytes.clone()) }; + let on_saved = Arc::ptr_eq(editor.cos.bytes(), &editor.saved); + if !editor.cos.is_modified() && on_saved && (!editor.cos.reconstructed() || doc.is_signed()) { + return Ok(editor.saved.clone()); + } + // What the user removed again before saving (a cleared redaction mark) stays out of the file. + let opts = SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), drop_unlinked: true, ..SaveOptions::default() }; + // A restored snapshot can be based on an earlier revision. Append to the last saved + // file so that revision stays in the chain. A full rewrite (redaction, new encryption, + // a reconstructed file) still writes the current graph on its own. + let rewrite = editor.cos.full_save_required() || editor.cos.encryption_changed() || editor.cos.revisions().is_empty(); + let cos = writable(&editor.cos)?; + guard(|| { + if on_saved || rewrite { + write_incremental(&cos, &opts) + } else { + let mut base = pdfcraft_cos::Document::open_with_password(editor.saved.clone(), editor.saved_keys.reopen.as_deref())?; + base.overlay_changes_from(&cos)?; + write_incremental(&base, &opts) + } + }) + .map_err(EditError::Write)? + .map(Arc::new) + .map_err(|e| EditError::Write(e.to_string())) + } + + /// A compact, garbage-collected rewrite (Save As ▸ "Optimized" / Reduce File Size groundwork). + pub fn save_full_bytes(&self, id: DocId) -> Result>, EditError> { + let doc = self.get(id).ok_or(EditError::NoDocument)?; + if doc.is_signed() { + return Err(EditError::Signed); + } + let editor = doc.editor.as_ref().ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))?; + let opts = SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), ..SaveOptions::default() }; + let cos = writable(&editor.cos)?; + guard(|| write_full(&cos, &opts)).map_err(EditError::Write)?.map(Arc::new).map_err(|e| EditError::Write(e.to_string())) + } + + /// Check that `bytes`, from [`Self::save_bytes`] or [`Self::save_full_bytes`], reopen as this + /// document before they are written (#1698): [`Self::mark_saved`] only finds out after the + /// file has been replaced. Same error as `mark_saved`. + pub fn check_save(&self, id: DocId, bytes: &Arc>) -> Result<(), EditError> { + let doc = self.get(id).ok_or(EditError::NoDocument)?; + let Some(editor) = doc.editor.as_ref() else { return Ok(()) }; + pdfcraft_cos::Document::open_with_password(bytes.clone(), editor.keys.reopen.as_deref()).map_err(|e| EditError::Reopen(e.to_string()))?; + pdfcraft_render::check_pages(bytes.clone(), doc.password.as_deref(), doc.info.pages.len()).map_err(|e| EditError::Reopen(e.to_string())) + } + + /// Record a successful save of `bytes` (to `path`, if any): rebase editing on the saved file + /// so the next save appends only newer edits. Undo history is kept. + pub fn mark_saved(&mut self, id: DocId, bytes: Arc>, path: Option) -> Result<(), EditError> { + let doc = self.doc_mut(id)?; + if let Some(editor) = doc.editor.as_mut() { + // An encrypted file needs the password it is protected with now (the owner + // password when known, so saving never downgrades this session's rights). + let keys = editor.keys.clone(); + // The saved file carries no Password-field values (#3943, #3944); the open document keeps them. + let typed = pdfcraft_forms::password_values(&editor.cos); + editor.cos = + pdfcraft_cos::Document::open_with_password(bytes.clone(), keys.reopen.as_deref()).map_err(|e| EditError::Reopen(e.to_string()))?; + pdfcraft_forms::restore_password_values(&mut editor.cos, &typed).map_err(|e| EditError::Reopen(e.to_string()))?; + if !typed.is_empty() && doc.info.xfa.is_some() { + let mut datasets = None; + let _ = xfa_sync_datasets(&mut editor.cos, doc.xfa_template.as_deref(), &mut datasets); + } + editor.saved = bytes; + editor.saved_keys = keys; + editor.unsaved_file = false; + } + if let Some(p) = path { + doc.name = std::path::Path::new(&p).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| p.clone()); + doc.path = Some(p); + } + doc.dirty = false; + doc.generation += 1; + Self::refresh(doc).inspect_err(|_| Self::show_released(doc)) + } + + /// Enter the permissions (owner) password of a document opened with its user password, + /// lifting its restrictions for this session (Acrobat asks for it before changing security). + /// Not an edit: nothing is marked unsaved and there is no undo step. Undo and redo states + /// are unlocked as well, and saving keeps the rights. Unencrypted documents and documents + /// opened by the owner need nothing. + pub fn unlock(&mut self, id: DocId, password: &str) -> Result<(), EditError> { + let doc = self.doc_mut(id)?; + let editor = doc.editor.as_mut().ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))?; + if editor.cos.permissions().is_none_or(|p| p.owner) { + return Ok(()); + } + editor.cos.unlock(password).map_err(|e| match e { + pdfcraft_cos::CosError::WrongPassword => EditError::WrongPassword, + other => EditError::Protection(other.to_string()), + })?; + // Saving a state that keeps the security it was opened with reopens it with the same + // passwords, so it reopens as owner. + let reopen_as_owner = |cos: &pdfcraft_cos::Document, keys: &mut Keys| { + if !cos.encryption_changed() { + keys.reopen = Some(password.to_owned()); + } + }; + reopen_as_owner(&editor.cos, &mut editor.keys); + // Other states with the same restricted access are unlocked too; states opened with + // other passwords (protection changed and saved since) stay as they are. + for (_, cos, keys, _) in editor.undo.iter_mut().chain(editor.redo.iter_mut()) { + if cos.permissions().is_some_and(|p| !p.owner) && cos.unlock(password).is_ok() { + reopen_as_owner(cos, keys); + } + } + Ok(()) + } + + /// File ▸ Revert: back to the last saved version (or the file as opened). Undo history is + /// cleared, as in Acrobat. + pub fn revert(&mut self, id: DocId) -> Result<(), EditError> { + let doc = self.doc_mut(id)?; + let editor = doc.editor.as_mut().ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))?; + let base = editor.saved.clone(); + // The saved file opens with the passwords it was saved with. The current snapshot may + // be an older revision (undo past that save), so its own base is not the saved file. + let mut candidates = vec![editor.saved_keys.clone(), editor.keys.clone()]; + candidates.extend(editor.undo.iter().rev().map(|s| s.2.clone())); + candidates.push(Keys::default()); + let (cos, keys) = candidates + .into_iter() + .find_map(|k| pdfcraft_cos::Document::open_with_password(base.clone(), k.reopen.as_deref()).ok().map(|c| (c, k))) + .ok_or_else(|| EditError::Reopen("the saved file can't be opened".into()))?; + editor.cos = cos; + editor.keys = keys; + editor.undo.clear(); + editor.redo.clear(); + Self::adopt_keys(doc); + Self::sync_dirty(doc); + doc.generation += 1; + Self::refresh(doc).inspect_err(|_| Self::show_released(doc)) + } + + /// The document's file changed on disk (a LaTeX or Typst build rewrote it, #431): open + /// `bytes`, the file as it is now, in its place. Everything opening does runs again (XFA + /// layout and scripts, signatures, repairs), but the document keeps its id, name and path, + /// so its tab and view stay with it. Undo history goes, as with Revert. The password it was + /// opened with is tried; on any failure the document is left as it was. + pub fn reload(&mut self, id: DocId, bytes: Arc>) -> Result<(), OpenError> { + let old = self.docs.iter().position(|d| d.id == id).ok_or_else(|| OpenError::Invalid("the document is not open".into()))?; + let doc = &self.docs[old]; + let (name, path, generation) = (doc.name.clone(), doc.path.clone(), doc.generation); + let password = doc.editor.as_ref().and_then(|e| e.keys.reopen.clone()).or_else(|| doc.password.clone()); + let new_id = self.open(name, path, bytes, password.as_deref())?; + let at = self.docs.iter().position(|d| d.id == new_id).ok_or_else(|| OpenError::Invalid("the new version was not kept".into()))?; + let mut doc = self.docs.remove(at); + doc.id = id; + // How the document is shown is the reader's, not the file's: reading colours and hidden + // comments stay as they were (#3443). `open` built the renderer with the defaults. + if let Some(old) = self.docs.get(old) + && (doc.config.reading_colors != old.config.reading_colors || doc.config.hide_comments != old.config.hide_comments) + { + doc.config.reading_colors = old.config.reading_colors; + doc.config.hide_comments = old.config.hide_comments; + doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config)); + } + // Caches keyed by the edit generation (edit lines, images, measurements) must see a new + // one; there is nothing new to autosave. + doc.generation = generation + 1; + doc.snapshot_generation = doc.generation; + // `open` pushed the new version last, so the old one is still at `old`. + *self.docs.get_mut(old).ok_or_else(|| OpenError::Invalid("the document is not open".into()))? = doc; + Ok(()) + } + + /// Whether a changed document still needs a recovery snapshot. This does not consume it. + pub fn has_pending_autosave(&self) -> bool { + self.docs.iter().any(|d| d.dirty && d.generation != d.snapshot_generation) + } + + /// Documents with unsaved changes made since the last call: their current working file, + /// for crash recovery. Encrypted documents stay encrypted in the snapshot. + pub fn autosave_snapshots(&mut self) -> Vec { + let mut out = Vec::new(); + for d in &mut self.docs { + if d.dirty && d.generation != d.snapshot_generation { + // The working bytes hold typed Password-field values (#3926, #3944): a recovery file + // is written from a copy without them, and none is written if that copy fails. + let bytes = + match d.editor.as_ref().filter(|e| pdfcraft_forms::has_password_values(&e.cos) || pdfcraft_xfa::has_password_values(&e.cos)) { + Some(e) => match writable(&e.cos) + .and_then(|c| write_incremental(&c, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))) + { + Ok(b) => Arc::new(b), + Err(_) => continue, + }, + None => d.bytes.clone(), + }; + d.snapshot_generation = d.generation; + out.push(RecoverySnapshot { + doc: d.id, + name: d.name.clone(), + path: d.path.clone(), + bytes, + encrypted: d.info.encrypted || d.editor.as_ref().is_some_and(|e| e.cos.security().is_some()), + }); + } + } + out + } + + /// Mark a document opened from a recovery file: it has unsaved changes and belongs at + /// `path` (where Save writes), as when the session ended. + pub fn mark_recovered(&mut self, id: DocId, path: Option) { + if let Some(d) = self.docs.iter_mut().find(|d| d.id == id) { + d.path = path; + d.dirty = true; + if let Some(e) = d.editor.as_mut() { + e.unsaved_file = true; + } + d.generation += 1; + d.snapshot_generation = d.generation; // its bytes are already in the recovery store + } + } + + /// The certificates trusted for signing. + pub fn trusted_certificates(&self) -> &[pdfcraft_sign::Certificate] { + &self.trust.certs + } + + /// Replace the trusted certificates, revalidate every open document's signatures, + /// and rebuild its display copy and renderer to match (working file untouched, + /// generation/dirty unchanged: trust alone is not an edit). + pub fn set_trusted_certificates(&mut self, certs: Vec) { + self.trust = Arc::new(TrustStore { certs }); + for doc in &mut self.docs { + doc.trust = self.trust.clone(); + let Some(editor) = doc.editor.as_mut() else { continue }; + // Keep verdict refresh and renderer refresh together. Viewer caches are + // invalidated by the UI trust path; trust alone changes no document artwork. + let signatures = if doc.signatures.is_empty() { Vec::new() } else { signatures_of(&editor.cos, &doc.bytes, &doc.trust, &doc.sig_cache) }; + doc.display = display_bytes(Some(editor), &doc.bytes); + doc.signatures = Arc::new(signatures); + doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config)); + } + } + + /// The signing time as a PDF date in local time with its offset (`D:…+02'00'`). + pub fn signing_date(&self) -> String { + 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 { + return format!("{stamp}Z"); + } + let (sign, m) = if offset < 0 { ('-', -offset / 60) } else { ('+', offset / 60) }; + format!("{stamp}{sign}{:02}'{:02}'", m / 60, m % 60) + } + + /// Sign the document's current state with `id` (Use a certificate ▸ Digitally sign). Returns + /// the signed file; the caller saves it and then calls [`Session::mark_signed`]. An empty + /// `opts.date` takes the session clock. + pub fn sign(&self, doc: DocId, id: &pdfcraft_sign::DigitalId, mut opts: SignOptions) -> Result>, EditError> { + let d = self.get(doc).ok_or(EditError::NoDocument)?; + // Encrypted documents are signed under their permissions (the signer checks them). + let editor = d.editor.as_ref().ok_or_else(|| EditError::ReadOnly(d.read_only_reason.clone().unwrap_or_default()))?; + if opts.date.is_empty() { + opts.date = self.signing_date(); + } + // A typed Password-field value is not signed into the file (#3926), as it isn't saved. + let cos = writable(&editor.cos)?; + Ok(Arc::new(pdfcraft_sign::sign(&cos, id, &opts)?)) + } + + /// [`Session::sign`], embedding an RFC 3161 signature timestamp (PAdES B-T) produced by + /// `tsa`. The transport lives with the caller; the engine never opens a socket. + pub fn sign_with_timestamp( + &self, + doc: DocId, + id: &pdfcraft_sign::DigitalId, + mut opts: SignOptions, + tsa: &dyn pdfcraft_sign::TimestampAuthority, + ) -> 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()))?; + if opts.date.is_empty() { + opts.date = self.signing_date(); + } + let cos = writable(&editor.cos)?; + let signed = Arc::new(pdfcraft_sign::sign_with_timestamp(&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 + /// current state. An empty `date` takes the session clock; the transport is the caller's. + pub fn timestamp_document(&self, doc: DocId, tsa: &dyn pdfcraft_sign::TimestampAuthority, date: String) -> 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()))?; + let date = if date.is_empty() { self.signing_date() } else { date }; + let cos = writable(&editor.cos)?; + let stamped = Arc::new(pdfcraft_sign::timestamp_document(&cos, tsa, &date)?); + self.check_save(doc, &stamped)?; + Ok(stamped) + } + + /// Embed revocation evidence into the catalog's `/DSS` with `/VRI` entries per signature + /// (PAdES B-LT): an incremental update that never rewrites signed bytes. + 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()))?; + let cos = writable(&editor.cos)?; + let embedded = Arc::new(pdfcraft_sign::dss::embed(&cos, evidence)?); + self.check_save(doc, &embedded)?; + Ok(embedded) + } + + /// Record that the signed file `bytes` was saved (to `path`): like [`Session::mark_saved`], + /// and the edit history before signing is dropped (signing can't be undone). + pub fn mark_signed(&mut self, id: DocId, bytes: Arc>, path: Option) -> Result<(), EditError> { + self.mark_saved(id, bytes, path)?; + let doc = self.doc_mut(id)?; + if let Some(e) = doc.editor.as_mut() { + e.undo.clear(); + e.redo.clear(); + } + Ok(()) + } +} diff --git a/crates/engine/src/session/view.rs b/crates/engine/src/session/view.rs new file mode 100644 index 0000000000..c0e7417c8e --- /dev/null +++ b/crates/engine/src/session/view.rs @@ -0,0 +1,307 @@ +use super::{ + Arc, BookmarkPage, BookmarkRow, DocId, Document, EditError, LayerOp, OpenError, ReadingColors, RenderConfig, RenderPool, Session, SummarySort, + apply_layer_state, comment_summary, display_config, local_utc_offset, outline_rows, parse_ymd, render_threads, use_layer_choices, +}; + +impl Session { + pub fn new() -> Self { + Self::default() + } + + /// Use a fixed clock (tests) instead of the system time. + pub fn with_clock(mut self, clock: fn() -> i64) -> Self { + self.clock = Some(clock); + self + } + + /// Multiply the time an XFA form's scripts may take by `factor` (tests): the 3 s the + /// scripts of an opening or a change share, the 5 s of a click's, the 10 s the script + /// engine has to start, and each script's own 1 s or 3 s. A test that checks what the + /// scripts do passes a large factor, so that a busy machine does not stop them. On native + /// builds, 0 stops every script before it starts; the wasm build has no wall clock, so + /// there the factor has no effect. + pub fn with_xfa_time_scale(mut self, factor: u32) -> Self { + self.xfa_time_scale = Some(factor); + 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. + pub(super) 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) + } + + pub(crate) fn now(&self) -> Option { + if let Some(c) = self.clock { + return Some(c()); + } + #[cfg(not(target_arch = "wasm32"))] + return std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).ok().map(|d| d.as_secs() as i64); + #[cfg(target_arch = "wasm32")] + return None; + } + + /// Today's date in local time: (year, month, day). With an injected clock (tests) the clock + /// 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 = 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)); + const MONTHS: [&str; 12] = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + let h12 = if hh % 12 == 0 { 12 } else { hh % 12 }; + let who = if author.trim().is_empty() { String::new() } else { format!("By {} ", author.trim()) }; + format!("{who}at {h12}:{mm:02} {}, {} {day:02}, {y}", if hh < 12 { "am" } else { "pm" }, MONTHS[(mo.clamp(1, 12) - 1) as usize]) + } + + pub fn today(&self) -> (i64, u32, u32) { + 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)) + } + + /// Open a document from bytes. Rendering starts lazily when pages are requested. + /// + /// `password` is tried as either the user or owner password when the file is encrypted. + /// Open saved revision `n` (1 = the oldest) of a document as a new, unsaved document named + /// " (revision n)". + pub fn open_revision(&mut self, id: DocId, n: usize) -> Result { + let doc = self.get(id).ok_or("no such document")?; + let ends = doc.revision_ends(); + let end = *n.checked_sub(1).and_then(|i| ends.get(i)).ok_or_else(|| format!("the document has {} revision(s)", ends.len()))?; + let name = format!("{} (revision {n}).pdf", doc.name.trim_end_matches(".pdf")); + let (bytes, password) = (Arc::new(doc.bytes[..end.min(doc.bytes.len())].to_vec()), doc.password.clone()); + self.open(name, None, bytes, password.as_deref()).map_err(|e| e.to_string()) + } + + pub(crate) fn doc_mut(&mut self, id: DocId) -> Result<&mut Document, EditError> { + self.docs.iter_mut().find(|d| d.id == id).ok_or(EditError::NoDocument) + } + + /// Web addresses in the text of every page (Create links from URLs): (page, one rectangle + /// per line in user space, URI). Text that already has a link over it is skipped. + pub fn find_urls(&self, id: DocId) -> Vec<(usize, Vec<[f64; 4]>, String)> { + let Some(doc) = self.get(id) else { return Vec::new() }; + let config = RenderConfig { password: doc.password.as_deref().map(Arc::from), ..Default::default() }; + let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), config); + let mut out = Vec::new(); + for page in 0..doc.info.pages.len() { + let res = r.render(pdfcraft_render::RenderRequest { page, kind: pdfcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() }); + let Some(text) = res.text else { continue }; + let found: std::cell::RefCell, String)>> = Default::default(); + let hits = text.find_with(|chars| { + let urls = pdfcraft_annot::links::find_urls(chars); + let ranges = urls.iter().map(|u| u.0.clone()).collect(); + *found.borrow_mut() = urls; + ranges + }); + for (glyphs, (_, uri)) in hits.into_iter().zip(found.into_inner()) { + let rects: Vec<[f64; 4]> = text + .line_rects(glyphs) + .into_iter() + .map(|v| { + let q = doc.info.pages[page].view_rect_to_quad(v); + let xs = [q[0], q[2], q[4], q[6]]; + let ys = [q[1], q[3], q[5], q[7]]; + [ + xs.iter().copied().fold(f64::MAX, f64::min), + ys.iter().copied().fold(f64::MAX, f64::min), + xs.iter().copied().fold(f64::MIN, f64::max), + ys.iter().copied().fold(f64::MIN, f64::max), + ] + }) + .filter(|r| { + !doc.links().iter().any(|l| l.page == page && l.rect[0] < r[2] && r[0] < l.rect[2] && l.rect[1] < r[3] && r[1] < l.rect[3]) + }) + .collect(); + if !rects.is_empty() { + out.push((page, rects, uri)); + } + } + } + out + } + + /// A page of the bookmark list in outline order: up to `limit` bookmarks from position `offset`. + /// The document's own structure is read for it, so every bookmark can be reached by paging. + pub fn bookmark_page(&self, id: DocId, offset: usize, limit: usize) -> Option { + 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| { + 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, color: b.color, italic: b.italic, bold: b.bold } + }) + .collect(); + return Some(BookmarkPage { rows, next: page.next, truncated: false }); + } + let all = outline_rows(&doc.info.outline); + let end = offset.saturating_add(limit); + let rows = all.iter().skip(offset).take(limit).cloned().collect(); + Some(BookmarkPage { rows, next: (end < all.len()).then_some(end), truncated: doc.info.outline_more }) + } + + /// Top-level bookmarks as split points: (first page of each part, its bookmark's title). + pub fn bookmark_splits(&self, id: DocId) -> Vec<(usize, String)> { + let Some(doc) = self.get(id) else { return Vec::new() }; + let top: Vec<(Option, String)> = match doc.editor.as_ref() { + Some(editor) => pdfcraft_organize::top_level_bookmarks(&editor.cos).into_iter().map(|b| (b.page, b.title)).collect(), + None => doc.info.outline.iter().map(|o| (o.page, o.title.clone())).collect(), + }; + let mut out: Vec<(usize, String)> = top.into_iter().filter_map(|(page, title)| Some((page?, title))).collect(); + out.sort_by_key(|x| x.0); + out.dedup_by_key(|x| x.0); + out + } + + /// Open freshly created bytes (combine / extract) as a new, unsaved document. + pub fn open_new(&mut self, name: impl Into, bytes: Arc>) -> Result { + let id = self.open(name, None, bytes, None)?; + if let Some(d) = self.docs.iter_mut().find(|d| d.id == id) { + d.dirty = true; + if let Some(e) = d.editor.as_mut() { + e.unsaved_file = true; + } + // Unsaved work with no edit yet still needs its first recovery snapshot. + d.generation += 1; + } + Ok(id) + } + + /// Show or hide a layer (optional content group) for viewing. Returns `true` if it changed; + /// callers must drop cached rasters and text for the document. + pub fn set_layer_visible(&mut self, id: DocId, layer: usize, visible: bool) -> bool { + let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return false }; + let Some(l) = doc.info.layers.get_mut(layer) else { return false }; + if l.visible == visible { + return false; + } + l.visible = visible; + use_layer_choices(doc); + true + } + + /// Run a set-layer-visibility action (`SetOCGState`): apply `changes` in order, naming each + /// layer by its optional content group; groups that aren't layers are skipped. With + /// `preserve_rb`, a layer turned on turns off the other layers of its radio-button groups. + /// Returns `true` if any layer changed; callers must drop cached rasters and text for the + /// document, as for [`Self::set_layer_visible`]. + pub fn set_layer_state(&mut self, id: DocId, changes: &[(LayerOp, (u32, u16))], preserve_rb: bool) -> bool { + let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return false }; + if !apply_layer_state(&mut doc.info.layers, &doc.info.layer_groups, changes, preserve_rb) { + return false; + } + use_layer_choices(doc); + true + } + + /// Show each layer as the file opens (Acrobat's Layers ▸ Reset to Initial Visibility). Returns + /// `true` if any layer changed; callers must drop cached rasters and text for the document, + /// as for [`Self::set_layer_visible`]. + pub fn reset_layers(&mut self, id: DocId) -> bool { + let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return false }; + let mut changed = false; + for l in &mut doc.info.layers { + changed |= l.visible != l.initial; + l.visible = l.initial; + } + if changed { + use_layer_choices(doc); + } + changed + } + + /// Hide or show every comment on the page (Acrobat's Comments ▸ Hide all comments). Form + /// fields and links still draw. Returns whether anything changed. + pub fn set_hide_comments(&mut self, id: DocId, hide: bool) -> bool { + let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return false }; + if doc.config.hide_comments == hide { + return false; + } + doc.config.hide_comments = hide; + doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config)); + doc.generation += 1; + true + } + + /// Set the document's reading colours (Acrobat's Replace Document Colors). Returns whether + /// anything changed; callers must drop cached rasters and text for the document. + pub fn set_reading_colors(&mut self, id: DocId, colors: ReadingColors) -> bool { + let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return false }; + if doc.config.reading_colors == colors { + return false; + } + doc.config.reading_colors = colors; + doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config)); + doc.generation += 1; + true + } + + /// The document's reading-colours setting; `None` when no such document is open. + pub fn reading_colors(&self, id: DocId) -> Option { + Some(self.get(id)?.config.reading_colors) + } + + /// How the document's pages are drawn for viewing: its password and view settings (layer + /// visibility, hidden comments, reading colours). `None` when no such document is open. + pub fn render_config(&self, id: DocId) -> Option { + Some(self.get(id)?.config.clone()) + } + + /// Acrobat's Summarize Comments ("Comments only" layout): a new PDF listing every comment + /// with its number, author, type, date, text and replies, grouped by page. + pub fn summarize_comments(&self, id: DocId, sort: SummarySort) -> Result>, EditError> { + let doc = self.get(id).ok_or(EditError::NoDocument)?; + let text = comment_summary(&doc.name, &doc.info.annotations, sort); + self.create_report(&format!("Summary of Comments on {}", doc.name), &text) + } + + pub fn close(&mut self, id: DocId) { + self.docs.retain(|d| d.id != id); + } + + pub fn get(&self, id: DocId) -> Option<&Document> { + self.docs.iter().find(|d| d.id == id) + } + + /// Read document `id`'s font list again if content edits since the last full inspection may + /// have changed it (Document Properties ▸ Fonts, `doc_info`). A no-op otherwise. + pub fn refresh_fonts(&mut self, id: DocId) { + let Some(doc) = self.docs.iter_mut().find(|d| d.id == id && d.fonts_stale) else { return }; + if let Ok(info) = pdfcraft_render::inspect(doc.bytes.clone(), doc.password.as_deref()) { + doc.info.fonts = info.fonts; + } + doc.fonts_stale = false; + } + + pub fn docs(&self) -> &[Document] { + &self.docs + } +} diff --git a/crates/engine/src/signature_image.rs b/crates/engine/src/signature_image.rs index daff674c19..ae8842ddfb 100644 --- a/crates/engine/src/signature_image.rs +++ b/crates/engine/src/signature_image.rs @@ -61,7 +61,7 @@ impl Document { pdfcraft_annot::props(&editor.cos, page, index).map(|p| p.opacity).filter(|v| v.is_finite()).unwrap_or(1.0).clamp(0.0, 1.0) as f32; let turn = pdfcraft_annot::picture_rotation(&editor.cos, page, index).map_err(|e| e.to_string())?; let mut cos = editor.cos.clone(); - pdfcraft_annot::delete_annotation(&mut cos, page, index).map_err(|e| e.to_string())?; + pdfcraft_annot::lift_annotation(&mut cos, page, index).map_err(|e| e.to_string())?; let bytes = Arc::new(pdfcraft_cos::write_incremental(&cos, &pdfcraft_cos::SaveOptions::default()).map_err(|e| e.to_string())?); Ok(Some(ImageSignaturePreview { image, opacity, turn, page, bytes, config: self.config.clone() })) })? diff --git a/crates/engine/src/signature_scan.rs b/crates/engine/src/signature_scan.rs index ba6ca269ab..4742754b01 100644 --- a/crates/engine/src/signature_scan.rs +++ b/crates/engine/src/signature_scan.rs @@ -602,303 +602,4 @@ fn render(area: &RgbImage, alpha: &[u8], bounds: Region) -> Result, Scan } #[cfg(test)] -mod tests { - use super::*; - - /// A gray "photo": `paper(x, y)` brightness with dark ink rectangles `[x, y, w, h]`. - fn photo(width: usize, height: usize, paper: impl Fn(usize, usize) -> u8, ink: &[[usize; 4]], ink_value: u8) -> Vec { - let mut out = vec![0; width * height]; - for y in 0..height { - for x in 0..width { - let inside = ink.iter().any(|r| x >= r[0] && x < r[0] + r[2] && y >= r[1] && y < r[1] + r[3]); - out[y * width + x] = if inside { ink_value } else { paper(x, y) }; - } - } - out - } - - fn rgb(gray: &[u8], width: usize, height: usize) -> RgbImage { - RgbImage::from_fn(width as u32, height as u32, |x, y| { - let v = gray[y as usize * width + x as usize]; - image::Rgb([v, v, v]) - }) - } - - fn jpeg(image: &RgbImage) -> Vec { - let mut out = Cursor::new(Vec::new()); - image.write_to(&mut out, ImageFormat::Jpeg).unwrap(); - out.into_inner() - } - - /// Photos keep the image signature limits (#1124): a 4 MiB file, at most 32 megapixels and - /// 8192 pixels per side decoded, and a working copy of at most 4 megapixels. - #[test] - fn photos_keep_the_image_signature_limits() { - let png = |image: &RgbImage| { - let mut out = Cursor::new(Vec::new()); - image.write_to(&mut out, ImageFormat::Png).unwrap(); - out.into_inner() - }; - assert!(matches!(SignaturePhoto::from_bytes(&vec![0; MAX_PHOTO_BYTES + 1]), Err(ScanError::Size))); - assert!(SignaturePhoto::read(std::io::repeat(0)).is_err()); - assert!(matches!(SignaturePhoto::from_bytes(&png(&RgbImage::new(8193, 1))), Err(ScanError::Size))); - assert!(matches!(SignaturePhoto::from_bytes(b"GIF89a\x02\0\x02\0"), Err(ScanError::Format))); - let [w, h] = SignaturePhoto::from_bytes(&png(&RgbImage::from_pixel(8000, 4000, image::Rgb([220, 220, 215])))).unwrap().size(); - assert!(w <= WORKING_MAX_SIDE && u64::from(w) * u64::from(h) <= MAX_OUTPUT_PIXELS, "{w} x {h}"); - assert!((f64::from(w) / f64::from(h) - 2.0).abs() < 0.01, "the aspect ratio is kept: {w} x {h}"); - } - - fn region(r: [usize; 4]) -> Region { - Region { x: r[0], y: r[1], width: r[2], height: r[3] } - } - - fn opts() -> Options { - Options { sensitivity: DEFAULT_SENSITIVITY, despeckle: true, background_radius: None } - } - - #[test] - fn box_blur_averages_and_keeps_a_constant_signal() { - let mut values = vec![0.0, 0.0, 9.0, 0.0, 0.0]; - box_blur(&mut values, 5, 1, 1); - assert_eq!(values, vec![0.0, 3.0, 3.0, 3.0, 0.0]); - let mut flat = vec![7.0; 12]; - box_blur(&mut flat, 4, 3, 2); - assert!(flat.iter().all(|v| (v - 7.0).abs() < 1e-4)); - } - - #[test] - fn square_filters_match_a_naive_window_with_repeated_edges() { - let (w, h) = (13, 7); - let src: Vec = (0..w * h).map(|i| ((i * 37 + 11) % 251) as u8).collect(); - for radius in [1, 2, 5] { - for max in [true, false] { - let fast = square_filter(&src, w, h, radius, max); - for y in 0..h { - for x in 0..w { - let mut want = if max { 0 } else { 255 }; - for dy in -(radius as i64)..=radius as i64 { - for dx in -(radius as i64)..=radius as i64 { - let sx = (x as i64 + dx).clamp(0, w as i64 - 1) as usize; - let sy = (y as i64 + dy).clamp(0, h as i64 - 1) as usize; - let v = src[sy * w + sx]; - want = if max { want.max(v) } else { want.min(v) }; - } - } - assert_eq!(fast[y * w + x], want, "radius {radius} max {max} at {x},{y}"); - } - } - } - } - } - - #[test] - fn components_are_eight_connected() { - let mask = [true, false, false, false, false, true, false, true, false, false, false, false]; - let (labels, count) = label_components(&mask, 4, 3); - assert_eq!(count, 2); - assert_eq!(labels[0], labels[5]); - assert_ne!(labels[7], labels[0]); - assert_eq!(labels[1], 0); - } - - #[test] - fn sensitivity_lowers_the_threshold() { - assert!(threshold(0) > threshold(50)); - assert!(threshold(50) > threshold(100)); - assert_eq!(threshold(200), threshold(100)); - } - - #[test] - fn ink_survives_a_shadow_gradient_and_the_shadow_does_not() { - let (w, h) = (160, 80); - let stroke = [20, 30, 120, 6]; - let gray = photo(w, h, |x, _| (235 - 115 * x / (w - 1)) as u8, &[stroke], 30); - let ink = extract_ink(&gray, w, h, opts()); - assert_eq!(ink.alpha[10 * w + 150], 0); - assert_eq!(ink.alpha[33 * w + 30], 255); - assert_eq!(ink.alpha[33 * w + 130], 255); - assert_eq!(ink.bounds, Some(region(stroke))); - } - - #[test] - fn a_sharp_shadow_edge_is_not_ink() { - let (w, h) = (200, 120); - let stroke = [30, 52, 140, 4]; - let gray = photo(w, h, |_, y| if y < 60 { 230 } else { 120 }, &[stroke], 30); - let ink = extract_ink(&gray, w, h, opts()); - for y in 58..63 { - assert_eq!(ink.alpha[y * w + 10], 0); - assert_eq!(ink.alpha[y * w + 190], 0); - } - assert_eq!(ink.bounds, Some(region(stroke))); - } - - #[test] - fn a_table_at_the_edge_of_the_frame_is_not_ink() { - let (w, h) = (300, 120); - let stroke = [60, 60, 200, 4]; - let gray = photo(w, h, |x, y| if x < 12 || y < 10 { 70 } else { 225 }, &[stroke], 30); - let ink = extract_ink(&gray, w, h, opts()); - assert_eq!(ink.alpha[5 * w + 150], 0); - assert_eq!(ink.alpha[60 * w + 5], 0); - assert_eq!(ink.alpha[11 * w + 150], 0); - assert_eq!(ink.bounds, Some(region(stroke))); - } - - #[test] - fn thick_strokes_stay_solid_when_the_window_is_sized_on_the_photo() { - let (w, h) = (600, 200); - let stroke = [50, 90, 500, 18]; - let gray = photo(w, h, |_, _| 225, &[stroke], 30); - let middle = 99 * w + 300; - // Sized on the tight frame alone, the window is narrower than the stroke. - assert_eq!(extract_ink(&gray, w, h, opts()).alpha[middle], 0); - let ink = extract_ink(&gray, w, h, Options { background_radius: Some((4.0 * w as f32 * BACKGROUND_RADIUS_RATIO) as usize), ..opts() }); - assert_eq!(ink.bounds, Some(region(stroke))); - assert!((90..108).all(|y| ink.alpha[y * w + 300] == 255)); - } - - #[test] - fn faint_stroke_ends_touching_ink_are_kept_and_isolated_ones_are_not() { - let (w, h) = (200, 60); - let mut gray = photo(w, h, |_, _| 220, &[[20, 28, 80, 4]], 40); - for y in 28..32 { - for x in (100..180).chain(10..15) { - gray[y * w + x] = 175; - } - } - let ink = extract_ink(&gray, w, h, opts()); - assert!(ink.alpha[30 * w + 150] > 0); - assert_eq!(ink.alpha[30 * w + 12], 0); - assert_eq!(ink.bounds, Some(region([20, 28, 160, 4]))); - } - - #[test] - fn dust_is_removed_unless_despeckling_is_off() { - let (w, h) = (200, 100); - let gray = photo(w, h, |_, _| 230, &[[20, 40, 150, 5], [190, 5, 1, 1]], 20); - assert!(extract_ink(&gray, w, h, Options { despeckle: false, ..opts() }).alpha[5 * w + 190] > 0); - let ink = extract_ink(&gray, w, h, opts()); - assert_eq!(ink.alpha[5 * w + 190], 0); - assert_eq!(ink.bounds, Some(region([20, 40, 150, 5]))); - } - - #[test] - fn faint_strokes_need_a_higher_sensitivity() { - let (w, h) = (120, 60); - let gray = photo(w, h, |_, _| 220, &[[10, 25, 100, 4]], 165); - assert!(extract_ink(&gray, w, h, Options { sensitivity: 0, ..opts() }).bounds.is_none()); - assert!(extract_ink(&gray, w, h, Options { sensitivity: 100, ..opts() }).bounds.is_some()); - } - - #[test] - fn blank_paper_has_no_ink() { - let gray = photo(64, 64, |x, y| (200 + (x * 7 + y * 13) % 20) as u8, &[], 0); - assert!(extract_ink(&gray, 64, 64, opts()).bounds.is_none()); - assert!(detect_region(&gray, 64, 64).is_none()); - } - - #[test] - fn detection_frames_all_words_and_ignores_dust_and_the_table_edge() { - let (w, h) = (400, 200); - let gray = photo(w, h, |_, y| if y >= 185 { 30 } else { 225 }, &[[40, 95, 90, 5], [230, 92, 30, 5], [300, 20, 1, 1]], 30); - let r = detect_region(&gray, w, h).unwrap(); - assert!(r.x <= 40 && r.x + r.width >= 260, "{r:?}"); - assert!(r.y > 20 && r.y + r.height < 185, "{r:?}"); - } - - #[test] - fn detection_frames_the_signature_not_the_sheet_on_a_dark_table() { - let (w, h) = (400, 300); - let sheet = |x: usize, y: usize| if (60..340).contains(&x) && (50..250).contains(&y) { 220 } else { 60 }; - let gray = photo(w, h, sheet, &[[120, 150, 140, 4], [150, 135, 4, 20]], 30); - let r = detect_region(&gray, w, h).unwrap(); - assert!(r.x > 80 && r.x + r.width < 320, "{r:?}"); - assert!(r.y > 70 && r.y + r.height < 230, "{r:?}"); - } - - #[test] - fn rects_turn_with_the_photo_and_back() { - let rect = PhotoRect { x: 10, y: 20, width: 30, height: 40 }; - let turned = rect.turned(Turn::Clockwise, 100, 80); - assert_eq!(turned, PhotoRect { x: 20, y: 10, width: 40, height: 30 }); - assert_eq!(turned.turned(Turn::CounterClockwise, 80, 100), rect); - assert_eq!(PhotoRect { x: 500, y: 0, width: 0, height: 900 }.clamped(100, 50), PhotoRect { x: 99, y: 0, width: 1, height: 50 }); - assert_eq!(PhotoRect { x: 0, y: 0, width: u32::MAX, height: 1 }.turned(Turn::CounterClockwise, 10, 10).y, 0); - } - - #[test] - fn a_photo_is_scanned_into_a_trimmed_transparent_signature_in_its_ink_colour() { - let (w, h) = (600, 400); - let mut image = rgb(&photo(w, h, |x, _| (230 - 60 * x / w) as u8, &[], 0), w, h); - for (x, y, p) in image.enumerate_pixels_mut() { - if (150..450).contains(&x) && (200..206).contains(&y) || (200..206).contains(&x) && (170..206).contains(&y) { - *p = image::Rgb([30, 50, 140]); - } - } - let mut photo = SignaturePhoto::from_bytes(&jpeg(&image)).unwrap(); - let rect = photo.auto_frame(); - assert!(rect.x <= 150 && rect.x + rect.width >= 450 && rect.y <= 170 && rect.y + rect.height >= 206, "{rect:?}"); - let signature = photo.scan(rect, DEFAULT_SENSITIVITY, SCAN_MAX_SIDE).unwrap(); - let [sw, sh] = signature.size(); - assert!((300..340).contains(&sw) && (36..60).contains(&sh), "{sw}x{sh}"); - let pixels = signature.rgba().as_chunks::<4>().0; - assert_eq!(pixels[0][3], 0, "the margin is transparent"); - let solid: Vec<_> = pixels.iter().filter(|p| p[3] == 255).collect(); - assert!(!solid.is_empty()); - assert!(solid.iter().all(|p| p[2] > p[0] + 40), "blue ink stays blue: {:?}", solid[0]); - let quick = photo.scan(rect, DEFAULT_SENSITIVITY, PREVIEW_MAX_SIDE).unwrap(); - assert!(quick.size()[0] <= sw); - } - - #[test] - fn a_sideways_signature_is_turned_upright() { - let (w, h) = (400, 600); - let gray = photo(w, h, |_, _| 225, &[[200, 120, 5, 300], [185, 300, 25, 4]], 30); - let mut photo = SignaturePhoto::from_bytes(&jpeg(&rgb(&gray, w, h))).unwrap(); - let rect = photo.auto_frame(); - assert_eq!(photo.size(), [600, 400]); - assert!(rect.width > rect.height * 2, "{rect:?}"); - } - - #[test] - fn an_empty_frame_reports_that_no_signature_was_found() { - let photo = SignaturePhoto::from_bytes(&jpeg(&RgbImage::from_pixel(200, 100, image::Rgb([220, 220, 220])))).unwrap(); - let mut copy = photo.clone(); - assert_eq!(copy.auto_frame(), PhotoRect { x: 28, y: 35, width: 142, height: 30 }); - assert!(matches!(photo.scan(PhotoRect { x: 0, y: 0, width: 200, height: 100 }, 50, SCAN_MAX_SIDE), Err(ScanError::NoInk))); - } - - #[test] - fn large_photos_are_downscaled_and_invalid_ones_rejected() { - let photo = SignaturePhoto::from_bytes(&jpeg(&RgbImage::from_pixel(5000, 100, image::Rgb([200, 200, 200])))).unwrap(); - let [w, h] = photo.size(); - assert!((4000..=WORKING_MAX_SIDE).contains(&w) && (81..=82).contains(&h), "{w} x {h}"); - assert!(photo.thumbnail(512).width() <= 512); - assert!(matches!(SignaturePhoto::from_bytes(b"not a photo"), Err(ScanError::Format))); - assert!(matches!(SignaturePhoto::from_bytes(b"GIF89a\x02\0\x02\0"), Err(ScanError::Format))); - assert!(SignaturePhoto::from_bytes(b"\xff\xd8\xff\xe0truncated").is_err()); - assert!(matches!(SignaturePhoto::from_bytes(&vec![0; MAX_PHOTO_BYTES + 1]), Err(ScanError::Size))); - assert!(SignaturePhoto::read(std::io::repeat(0)).is_err()); - // A header claiming a huge image is rejected before decoding. - let mut png = Vec::new(); - png.extend_from_slice(b"\x89PNG\r\n\x1a\n\0\0\0\x0dIHDR"); - png.extend_from_slice(&20_000_u32.to_be_bytes()); - png.extend_from_slice(&20_000_u32.to_be_bytes()); - png.extend_from_slice(&[8, 2, 0, 0, 0, 0, 0, 0, 0]); - assert!(SignaturePhoto::from_bytes(&png).is_err()); - } - - #[test] - fn degenerate_buffers_do_not_panic() { - assert!(extract_ink(&[], 0, 0, opts()).bounds.is_none()); - assert!(extract_ink(&[0; 3], 2, 2, opts()).bounds.is_none()); - assert!(extract_ink(&[0], 1, 1, opts()).bounds.is_none()); - assert!(detect_region(&[255; 4], 2, 2).is_none()); - let mut empty: Vec = Vec::new(); - box_blur(&mut empty, 0, 0, 3); - assert!(square_filter(&[], 0, 0, 2, true).is_empty()); - let photo = SignaturePhoto::from_bytes(&jpeg(&RgbImage::from_pixel(3, 2, image::Rgb([10, 10, 10])))).unwrap(); - let _ = photo.scan(PhotoRect { x: 9, y: 9, width: 0, height: 0 }, 255, 0); - } -} +mod tests; diff --git a/crates/engine/src/signature_scan/tests.rs b/crates/engine/src/signature_scan/tests.rs new file mode 100644 index 0000000000..c13200b1f1 --- /dev/null +++ b/crates/engine/src/signature_scan/tests.rs @@ -0,0 +1,298 @@ +use super::*; + +/// A gray "photo": `paper(x, y)` brightness with dark ink rectangles `[x, y, w, h]`. +fn photo(width: usize, height: usize, paper: impl Fn(usize, usize) -> u8, ink: &[[usize; 4]], ink_value: u8) -> Vec { + let mut out = vec![0; width * height]; + for y in 0..height { + for x in 0..width { + let inside = ink.iter().any(|r| x >= r[0] && x < r[0] + r[2] && y >= r[1] && y < r[1] + r[3]); + out[y * width + x] = if inside { ink_value } else { paper(x, y) }; + } + } + out +} + +fn rgb(gray: &[u8], width: usize, height: usize) -> RgbImage { + RgbImage::from_fn(width as u32, height as u32, |x, y| { + let v = gray[y as usize * width + x as usize]; + image::Rgb([v, v, v]) + }) +} + +fn jpeg(image: &RgbImage) -> Vec { + let mut out = Cursor::new(Vec::new()); + image.write_to(&mut out, ImageFormat::Jpeg).unwrap(); + out.into_inner() +} + +/// Photos keep the image signature limits (#1124): a 4 MiB file, at most 32 megapixels and +/// 8192 pixels per side decoded, and a working copy of at most 4 megapixels. +#[test] +fn photos_keep_the_image_signature_limits() { + let png = |image: &RgbImage| { + let mut out = Cursor::new(Vec::new()); + image.write_to(&mut out, ImageFormat::Png).unwrap(); + out.into_inner() + }; + assert!(matches!(SignaturePhoto::from_bytes(&vec![0; MAX_PHOTO_BYTES + 1]), Err(ScanError::Size))); + assert!(SignaturePhoto::read(std::io::repeat(0)).is_err()); + assert!(matches!(SignaturePhoto::from_bytes(&png(&RgbImage::new(8193, 1))), Err(ScanError::Size))); + assert!(matches!(SignaturePhoto::from_bytes(b"GIF89a\x02\0\x02\0"), Err(ScanError::Format))); + let [w, h] = SignaturePhoto::from_bytes(&png(&RgbImage::from_pixel(8000, 4000, image::Rgb([220, 220, 215])))).unwrap().size(); + assert!(w <= WORKING_MAX_SIDE && u64::from(w) * u64::from(h) <= MAX_OUTPUT_PIXELS, "{w} x {h}"); + assert!((f64::from(w) / f64::from(h) - 2.0).abs() < 0.01, "the aspect ratio is kept: {w} x {h}"); +} + +fn region(r: [usize; 4]) -> Region { + Region { x: r[0], y: r[1], width: r[2], height: r[3] } +} + +fn opts() -> Options { + Options { sensitivity: DEFAULT_SENSITIVITY, despeckle: true, background_radius: None } +} + +#[test] +fn box_blur_averages_and_keeps_a_constant_signal() { + let mut values = vec![0.0, 0.0, 9.0, 0.0, 0.0]; + box_blur(&mut values, 5, 1, 1); + assert_eq!(values, vec![0.0, 3.0, 3.0, 3.0, 0.0]); + let mut flat = vec![7.0; 12]; + box_blur(&mut flat, 4, 3, 2); + assert!(flat.iter().all(|v| (v - 7.0).abs() < 1e-4)); +} + +#[test] +fn square_filters_match_a_naive_window_with_repeated_edges() { + let (w, h) = (13, 7); + let src: Vec = (0..w * h).map(|i| ((i * 37 + 11) % 251) as u8).collect(); + for radius in [1, 2, 5] { + for max in [true, false] { + let fast = square_filter(&src, w, h, radius, max); + for y in 0..h { + for x in 0..w { + let mut want = if max { 0 } else { 255 }; + for dy in -(radius as i64)..=radius as i64 { + for dx in -(radius as i64)..=radius as i64 { + let sx = (x as i64 + dx).clamp(0, w as i64 - 1) as usize; + let sy = (y as i64 + dy).clamp(0, h as i64 - 1) as usize; + let v = src[sy * w + sx]; + want = if max { want.max(v) } else { want.min(v) }; + } + } + assert_eq!(fast[y * w + x], want, "radius {radius} max {max} at {x},{y}"); + } + } + } + } +} + +#[test] +fn components_are_eight_connected() { + let mask = [true, false, false, false, false, true, false, true, false, false, false, false]; + let (labels, count) = label_components(&mask, 4, 3); + assert_eq!(count, 2); + assert_eq!(labels[0], labels[5]); + assert_ne!(labels[7], labels[0]); + assert_eq!(labels[1], 0); +} + +#[test] +fn sensitivity_lowers_the_threshold() { + assert!(threshold(0) > threshold(50)); + assert!(threshold(50) > threshold(100)); + assert_eq!(threshold(200), threshold(100)); +} + +#[test] +fn ink_survives_a_shadow_gradient_and_the_shadow_does_not() { + let (w, h) = (160, 80); + let stroke = [20, 30, 120, 6]; + let gray = photo(w, h, |x, _| (235 - 115 * x / (w - 1)) as u8, &[stroke], 30); + let ink = extract_ink(&gray, w, h, opts()); + assert_eq!(ink.alpha[10 * w + 150], 0); + assert_eq!(ink.alpha[33 * w + 30], 255); + assert_eq!(ink.alpha[33 * w + 130], 255); + assert_eq!(ink.bounds, Some(region(stroke))); +} + +#[test] +fn a_sharp_shadow_edge_is_not_ink() { + let (w, h) = (200, 120); + let stroke = [30, 52, 140, 4]; + let gray = photo(w, h, |_, y| if y < 60 { 230 } else { 120 }, &[stroke], 30); + let ink = extract_ink(&gray, w, h, opts()); + for y in 58..63 { + assert_eq!(ink.alpha[y * w + 10], 0); + assert_eq!(ink.alpha[y * w + 190], 0); + } + assert_eq!(ink.bounds, Some(region(stroke))); +} + +#[test] +fn a_table_at_the_edge_of_the_frame_is_not_ink() { + let (w, h) = (300, 120); + let stroke = [60, 60, 200, 4]; + let gray = photo(w, h, |x, y| if x < 12 || y < 10 { 70 } else { 225 }, &[stroke], 30); + let ink = extract_ink(&gray, w, h, opts()); + assert_eq!(ink.alpha[5 * w + 150], 0); + assert_eq!(ink.alpha[60 * w + 5], 0); + assert_eq!(ink.alpha[11 * w + 150], 0); + assert_eq!(ink.bounds, Some(region(stroke))); +} + +#[test] +fn thick_strokes_stay_solid_when_the_window_is_sized_on_the_photo() { + let (w, h) = (600, 200); + let stroke = [50, 90, 500, 18]; + let gray = photo(w, h, |_, _| 225, &[stroke], 30); + let middle = 99 * w + 300; + // Sized on the tight frame alone, the window is narrower than the stroke. + assert_eq!(extract_ink(&gray, w, h, opts()).alpha[middle], 0); + let ink = extract_ink(&gray, w, h, Options { background_radius: Some((4.0 * w as f32 * BACKGROUND_RADIUS_RATIO) as usize), ..opts() }); + assert_eq!(ink.bounds, Some(region(stroke))); + assert!((90..108).all(|y| ink.alpha[y * w + 300] == 255)); +} + +#[test] +fn faint_stroke_ends_touching_ink_are_kept_and_isolated_ones_are_not() { + let (w, h) = (200, 60); + let mut gray = photo(w, h, |_, _| 220, &[[20, 28, 80, 4]], 40); + for y in 28..32 { + for x in (100..180).chain(10..15) { + gray[y * w + x] = 175; + } + } + let ink = extract_ink(&gray, w, h, opts()); + assert!(ink.alpha[30 * w + 150] > 0); + assert_eq!(ink.alpha[30 * w + 12], 0); + assert_eq!(ink.bounds, Some(region([20, 28, 160, 4]))); +} + +#[test] +fn dust_is_removed_unless_despeckling_is_off() { + let (w, h) = (200, 100); + let gray = photo(w, h, |_, _| 230, &[[20, 40, 150, 5], [190, 5, 1, 1]], 20); + assert!(extract_ink(&gray, w, h, Options { despeckle: false, ..opts() }).alpha[5 * w + 190] > 0); + let ink = extract_ink(&gray, w, h, opts()); + assert_eq!(ink.alpha[5 * w + 190], 0); + assert_eq!(ink.bounds, Some(region([20, 40, 150, 5]))); +} + +#[test] +fn faint_strokes_need_a_higher_sensitivity() { + let (w, h) = (120, 60); + let gray = photo(w, h, |_, _| 220, &[[10, 25, 100, 4]], 165); + assert!(extract_ink(&gray, w, h, Options { sensitivity: 0, ..opts() }).bounds.is_none()); + assert!(extract_ink(&gray, w, h, Options { sensitivity: 100, ..opts() }).bounds.is_some()); +} + +#[test] +fn blank_paper_has_no_ink() { + let gray = photo(64, 64, |x, y| (200 + (x * 7 + y * 13) % 20) as u8, &[], 0); + assert!(extract_ink(&gray, 64, 64, opts()).bounds.is_none()); + assert!(detect_region(&gray, 64, 64).is_none()); +} + +#[test] +fn detection_frames_all_words_and_ignores_dust_and_the_table_edge() { + let (w, h) = (400, 200); + let gray = photo(w, h, |_, y| if y >= 185 { 30 } else { 225 }, &[[40, 95, 90, 5], [230, 92, 30, 5], [300, 20, 1, 1]], 30); + let r = detect_region(&gray, w, h).unwrap(); + assert!(r.x <= 40 && r.x + r.width >= 260, "{r:?}"); + assert!(r.y > 20 && r.y + r.height < 185, "{r:?}"); +} + +#[test] +fn detection_frames_the_signature_not_the_sheet_on_a_dark_table() { + let (w, h) = (400, 300); + let sheet = |x: usize, y: usize| if (60..340).contains(&x) && (50..250).contains(&y) { 220 } else { 60 }; + let gray = photo(w, h, sheet, &[[120, 150, 140, 4], [150, 135, 4, 20]], 30); + let r = detect_region(&gray, w, h).unwrap(); + assert!(r.x > 80 && r.x + r.width < 320, "{r:?}"); + assert!(r.y > 70 && r.y + r.height < 230, "{r:?}"); +} + +#[test] +fn rects_turn_with_the_photo_and_back() { + let rect = PhotoRect { x: 10, y: 20, width: 30, height: 40 }; + let turned = rect.turned(Turn::Clockwise, 100, 80); + assert_eq!(turned, PhotoRect { x: 20, y: 10, width: 40, height: 30 }); + assert_eq!(turned.turned(Turn::CounterClockwise, 80, 100), rect); + assert_eq!(PhotoRect { x: 500, y: 0, width: 0, height: 900 }.clamped(100, 50), PhotoRect { x: 99, y: 0, width: 1, height: 50 }); + assert_eq!(PhotoRect { x: 0, y: 0, width: u32::MAX, height: 1 }.turned(Turn::CounterClockwise, 10, 10).y, 0); +} + +#[test] +fn a_photo_is_scanned_into_a_trimmed_transparent_signature_in_its_ink_colour() { + let (w, h) = (600, 400); + let mut image = rgb(&photo(w, h, |x, _| (230 - 60 * x / w) as u8, &[], 0), w, h); + for (x, y, p) in image.enumerate_pixels_mut() { + if (150..450).contains(&x) && (200..206).contains(&y) || (200..206).contains(&x) && (170..206).contains(&y) { + *p = image::Rgb([30, 50, 140]); + } + } + let mut photo = SignaturePhoto::from_bytes(&jpeg(&image)).unwrap(); + let rect = photo.auto_frame(); + assert!(rect.x <= 150 && rect.x + rect.width >= 450 && rect.y <= 170 && rect.y + rect.height >= 206, "{rect:?}"); + let signature = photo.scan(rect, DEFAULT_SENSITIVITY, SCAN_MAX_SIDE).unwrap(); + let [sw, sh] = signature.size(); + assert!((300..340).contains(&sw) && (36..60).contains(&sh), "{sw}x{sh}"); + let pixels = signature.rgba().as_chunks::<4>().0; + assert_eq!(pixels[0][3], 0, "the margin is transparent"); + let solid: Vec<_> = pixels.iter().filter(|p| p[3] == 255).collect(); + assert!(!solid.is_empty()); + assert!(solid.iter().all(|p| p[2] > p[0] + 40), "blue ink stays blue: {:?}", solid[0]); + let quick = photo.scan(rect, DEFAULT_SENSITIVITY, PREVIEW_MAX_SIDE).unwrap(); + assert!(quick.size()[0] <= sw); +} + +#[test] +fn a_sideways_signature_is_turned_upright() { + let (w, h) = (400, 600); + let gray = photo(w, h, |_, _| 225, &[[200, 120, 5, 300], [185, 300, 25, 4]], 30); + let mut photo = SignaturePhoto::from_bytes(&jpeg(&rgb(&gray, w, h))).unwrap(); + let rect = photo.auto_frame(); + assert_eq!(photo.size(), [600, 400]); + assert!(rect.width > rect.height * 2, "{rect:?}"); +} + +#[test] +fn an_empty_frame_reports_that_no_signature_was_found() { + let photo = SignaturePhoto::from_bytes(&jpeg(&RgbImage::from_pixel(200, 100, image::Rgb([220, 220, 220])))).unwrap(); + let mut copy = photo.clone(); + assert_eq!(copy.auto_frame(), PhotoRect { x: 28, y: 35, width: 142, height: 30 }); + assert!(matches!(photo.scan(PhotoRect { x: 0, y: 0, width: 200, height: 100 }, 50, SCAN_MAX_SIDE), Err(ScanError::NoInk))); +} + +#[test] +fn large_photos_are_downscaled_and_invalid_ones_rejected() { + let photo = SignaturePhoto::from_bytes(&jpeg(&RgbImage::from_pixel(5000, 100, image::Rgb([200, 200, 200])))).unwrap(); + let [w, h] = photo.size(); + assert!((4000..=WORKING_MAX_SIDE).contains(&w) && (81..=82).contains(&h), "{w} x {h}"); + assert!(photo.thumbnail(512).width() <= 512); + assert!(matches!(SignaturePhoto::from_bytes(b"not a photo"), Err(ScanError::Format))); + assert!(matches!(SignaturePhoto::from_bytes(b"GIF89a\x02\0\x02\0"), Err(ScanError::Format))); + assert!(SignaturePhoto::from_bytes(b"\xff\xd8\xff\xe0truncated").is_err()); + assert!(matches!(SignaturePhoto::from_bytes(&vec![0; MAX_PHOTO_BYTES + 1]), Err(ScanError::Size))); + assert!(SignaturePhoto::read(std::io::repeat(0)).is_err()); + // A header claiming a huge image is rejected before decoding. + let mut png = Vec::new(); + png.extend_from_slice(b"\x89PNG\r\n\x1a\n\0\0\0\x0dIHDR"); + png.extend_from_slice(&20_000_u32.to_be_bytes()); + png.extend_from_slice(&20_000_u32.to_be_bytes()); + png.extend_from_slice(&[8, 2, 0, 0, 0, 0, 0, 0, 0]); + assert!(SignaturePhoto::from_bytes(&png).is_err()); +} + +#[test] +fn degenerate_buffers_do_not_panic() { + assert!(extract_ink(&[], 0, 0, opts()).bounds.is_none()); + assert!(extract_ink(&[0; 3], 2, 2, opts()).bounds.is_none()); + assert!(extract_ink(&[0], 1, 1, opts()).bounds.is_none()); + assert!(detect_region(&[255; 4], 2, 2).is_none()); + let mut empty: Vec = Vec::new(); + box_blur(&mut empty, 0, 0, 3); + assert!(square_filter(&[], 0, 0, 2, true).is_empty()); + let photo = SignaturePhoto::from_bytes(&jpeg(&RgbImage::from_pixel(3, 2, image::Rgb([10, 10, 10])))).unwrap(); + let _ = photo.scan(PhotoRect { x: 9, y: 9, width: 0, height: 0 }, 255, 0); +} diff --git a/crates/engine/src/sources.rs b/crates/engine/src/sources.rs index 3c8083162e..91d6efa3ee 100644 --- a/crates/engine/src/sources.rs +++ b/crates/engine/src/sources.rs @@ -25,6 +25,24 @@ pub(crate) fn open_source_with(name: &str, bytes: &Arc>, password: Optio source_document(bytes, password).map_err(|p| EditError::Source(format!("{name}: {p}"))) } +/// Import the comments of `src` onto the pages of `doc` with the same numbers. +pub(crate) fn import_pdf_comments(doc: &mut pdfcraft_cos::Document, src: &pdfcraft_cos::Document) -> Result<(), EditError> { + if pdfcraft_organize::import_comments(doc, src)? == 0 { + return Err(pdfcraft_xfdf::DataError::NothingImported.into()); + } + Ok(()) +} + +/// Parse another PDF to import comments from, whatever its permissions allow. +pub(crate) fn comment_source(bytes: &Arc>) -> Result { + match std::panic::catch_unwind(|| pdfcraft_cos::Document::open(bytes.clone())) { + Ok(Ok(d)) => Ok(d), + Ok(Err(pdfcraft_cos::CosError::NeedsPassword)) => Err(SourceProblem::Password), + Ok(Err(e)) => Err(SourceProblem::Unreadable(e.to_string())), + Err(_) => Err(SourceProblem::Unreadable("the file could not be read".into())), + } +} + fn source_document(bytes: &Arc>, password: Option<&str>) -> Result { match std::panic::catch_unwind(|| pdfcraft_cos::Document::open_with_password(bytes.clone(), password)) { Ok(Ok(d)) if d.permissions().is_some_and(|p| !p.assemble()) => Err(SourceProblem::NotPermitted), diff --git a/crates/engine/src/stamp_preview.rs b/crates/engine/src/stamp_preview.rs index 6e5758a7ce..630ec0c3f0 100644 --- a/crates/engine/src/stamp_preview.rs +++ b/crates/engine/src/stamp_preview.rs @@ -1,4 +1,5 @@ -//! A stamp while it is moved or resized: the page without it, and the stamp alone. +//! A stamp while it is moved or resized: the page without it, and the stamp alone. Any other +//! comment while it is moved: the comment alone. use std::sync::Arc; @@ -73,7 +74,7 @@ impl Document { return Ok(None); } let mut background = editor.cos.clone(); - pdfcraft_annot::delete_annotation(&mut background, page, index).map_err(|e| e.to_string())?; + pdfcraft_annot::lift_annotation(&mut background, page, index).map_err(|e| e.to_string())?; // The page beneath as it is displayed: comments without an appearance get theirs. let background = pdfcraft_annot::with_missing_appearances(&background).unwrap_or(background); let write = |cos: &pdfcraft_cos::Document| write_incremental(cos, &SaveOptions::default()).map(Arc::new).map_err(|e| e.to_string()); @@ -81,3 +82,59 @@ impl Document { })? } } + +/// Comments alone on a transparent page, for drawing them where they go while they are moved. +/// The page beneath keeps showing them where they were until the move is applied: rendering the page +/// without it would cost a whole page's render at every drag on a heavy plan. Read-only, as +/// [`StampPreview`] is. +pub struct CommentLayer { + page: usize, + bytes: Arc>, + config: RenderConfig, +} + +impl CommentLayer { + /// One worker for the comments alone: their page has no content and no other annotations. + pub fn renderer(&self) -> RenderPool { + RenderPool::new(self.bytes.clone(), 1, self.transparent()) + } + + /// The comments alone, rendered synchronously with the renderer's normal pixel limits. + pub fn render(&self, scale: f32) -> Result { + if !scale.is_finite() || scale <= 0.0 { + return Err("preview scale must be finite and positive".into()); + } + guard(|| { + let out = + PageRenderer::new(self.bytes.clone(), self.transparent()).render(RenderRequest { page: self.page, scale, ..Default::default() }); + if let Some(e) = &out.error { + return Err(e.clone()); + } + Ok(out) + })? + } + + fn transparent(&self) -> RenderConfig { + RenderConfig { transparent: true, ..self.config.clone() } + } +} + +impl Document { + /// The comments at `indices` on `page` alone (the ones moved together), drawn as on screen: + /// one without an appearance of its own gets the one the page view draws. `None` when one + /// of them doesn't exist or none has anything to draw. + pub fn comment_layer(&self, page: usize, indices: &[usize]) -> Result, String> { + guard(|| { + let editor = self.editor.as_ref().ok_or("the document can't be read")?; + if indices.is_empty() || !indices.iter().all(|&i| self.info.annotations.iter().any(|a| a.page == page && a.index == i)) { + return Ok(None); + } + let mut alone = pdfcraft_annot::with_missing_appearances(&editor.cos).unwrap_or_else(|| editor.cos.clone()); + if !pdfcraft_annot::isolate_annotations(&mut alone, page, indices).map_err(|e| e.to_string())? { + return Ok(None); + } + let bytes = write_incremental(&alone, &SaveOptions::default()).map(Arc::new).map_err(|e| e.to_string())?; + Ok(Some(CommentLayer { page, bytes, config: self.config.clone() })) + })? + } +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index c622af21b4..a77e471a7d 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use super::document::is_placeholder_title; -use super::edit::check_permission; +use super::edit::{EditCtx, check_permission, run_edit}; use super::*; use pdfcraft_render::{Layer, LayerOp, OpenError, ReadingColors}; @@ -33,6 +33,39 @@ pub(crate) fn fixture(n: usize) -> Vec { out } +fn fixture_with_rotation_and_user_unit(rotate: i64, user_unit: f64) -> Vec { + let mut objs: Vec> = vec![b"<< /Type /Catalog /Pages 2 0 R >>".to_vec()]; + objs.push(b"<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 200 300] >>".to_vec()); + objs.push(b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec()); + let mut page_dict = String::from("<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /Font << /F1 3 0 R >> >>"); + if rotate != 0 { + page_dict.push_str(&format!(" /Rotate {rotate}")); + } + if (user_unit - 1.0).abs() > 1e-6 { + page_dict.push_str(&format!(" /UserUnit {user_unit}")); + } + page_dict.push_str(" >>"); + objs.push(page_dict.into_bytes()); + let body = "BT /F1 24 Tf 20 150 Td (Page 1) Tj ET"; + objs.push(format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()).into_bytes()); + + let mut out = b"%PDF-1.7\n".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\n", i + 1).as_bytes()); + out.extend_from_slice(o); + out.extend_from_slice(b"\nendobj\n"); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + fn session_with(n: usize) -> (Session, DocId) { let mut s = Session::new().with_clock(|| 1_700_000_000); let id = s.open("fixture.pdf", None, Arc::new(fixture(n)), None).expect("opens"); @@ -581,6 +614,80 @@ fn edits_update_view_data_and_mark_dirty() { assert_eq!(page_texts(&s, id), ["Page 1", "Page 3"], "renderer refreshed"); } +/// A font of an Adobe CJK collection that a PDF does not embed is drawn with an installed face on +/// the viewer's pool only. An export of the same page, which can be saved, has nothing drawn with +/// an installed font (AGENTS.md §1.2, §1.4). Skipped where no such face is installed. +#[test] +fn installed_cjk_fonts_draw_in_the_viewer_but_not_in_an_export() { + let content = "BT /F1 40 Tf 5 15 Td <7E419AD45B57> Tj ET"; + let pdf = format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 60] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj +4 0 obj << /Length {} >> stream +{content} +endstream endobj +5 0 obj << /Type /Font /Subtype /Type0 /BaseFont /MSung-Light /Encoding /UniCNS-UCS2-H /DescendantFonts [6 0 R] >> endobj +6 0 obj << /Type /Font /Subtype /CIDFontType0 /BaseFont /MSung-Light /CIDSystemInfo << /Registry (Adobe) /Ordering (CNS1) /Supplement 2 >> /FontDescriptor 7 0 R /DW 1000 >> endobj +7 0 obj << /Type /FontDescriptor /FontName /MSung-Light /Flags 6 /FontBBox [0 -141 1000 859] /ItalicAngle 0 /Ascent 859 /Descent -141 /CapHeight 700 /StemV 80 >> endobj +trailer << /Root 1 0 R >> +%%EOF", + content.len() + ); + let mut s = Session::new(); + let id = s.open("cns1.pdf", None, Arc::new(pdf.into_bytes()), None).unwrap(); + let doc = s.get(id).unwrap(); + doc.renderer.set_queue(vec![pdfcraft_render::RenderRequest { + page: 0, + kind: pdfcraft_render::RequestKind::Pixels, + scale: 1.0, + ..Default::default() + }]); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + let viewer = loop { + if let Some(page) = doc.renderer.try_recv() { + assert!(page.error.is_none(), "{:?}", page.error); + break page; + } + assert!(std::time::Instant::now() < deadline, "page render did not finish"); + std::thread::yield_now(); + }; + let inked = |rgba: &[u8]| rgba.as_chunks::<4>().0.iter().filter(|c| c[0] < 128).count(); + // Whether this machine has a face that can draw the page at all, asked of a renderer with the + // fallback switched on by hand, so a viewer pool that lost it fails below instead of skipping. + let probe = pdfcraft_render::PageRenderer::new(doc.display.clone(), pdfcraft_render::RenderConfig { system_fonts: true, ..Default::default() }) + .render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + assert!(probe.error.is_none(), "{:?}", probe.error); + if inked(&probe.rgba) < 300 { + eprintln!("no installed Traditional Chinese face (or PDFCRAFT_SYSTEM_FONTS=0): skipped"); + return; + } + assert!(inked(&viewer.rgba) > 300, "the viewer's pool draws it with the installed face ({} dark pixels)", inked(&viewer.rgba)); + let png = export::Exporter::new(doc).png(0, 72.0).unwrap(); + let exported = image::load_from_memory(&png).unwrap().into_rgba8(); + assert!(inked(exported.as_raw()) < 50, "an export draws nothing with an installed font ({} dark pixels)", inked(exported.as_raw())); + + // Reload rebuilds the pool when retaining a view preference. That path must keep + // the display-only fallback too; otherwise Chinese disappears after a file refresh. + let bytes = doc.bytes.clone(); + assert!(s.set_hide_comments(id, true)); + s.reload(id, bytes).unwrap(); + let doc = s.get(id).unwrap(); + assert!(doc.comments_hidden()); + doc.renderer.set_queue(vec![pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }]); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + let reloaded = loop { + if let Some(page) = doc.renderer.try_recv() { + assert!(page.error.is_none(), "{:?}", page.error); + break page; + } + assert!(std::time::Instant::now() < deadline, "reloaded page render did not finish"); + std::thread::yield_now(); + }; + assert!(inked(&reloaded.rgba) > 300, "reload must retain visible Chinese text"); +} + #[test] fn metadata_edit_keeps_the_renderer_without_changing_page_content() { let (mut s, id) = session_with(1); @@ -860,9 +967,10 @@ fn revert_preserves_the_saved_pages_after_undo() { #[test] fn full_save_is_readable_and_smaller_after_deletes() { let (mut s, id) = session_with(5); + let original = s.save_bytes(id).unwrap(); s.apply(id, Edit::DeletePages { pages: vec![0, 1, 2] }).unwrap(); let full = s.save_full_bytes(id).unwrap(); - assert!(full.len() < s.save_bytes(id).unwrap().len()); + assert!(full.len() < original.len()); let mut s2 = Session::new(); let id2 = s2.open("full.pdf", None, full, None).unwrap(); assert_eq!(page_texts(&s2, id2), ["Page 4", "Page 5"]); @@ -1849,6 +1957,153 @@ fn bookmarks_from_structure_nest_the_headings_and_undo() { assert!(s.get(id).unwrap().info.outline.is_empty()); } +/// Three pages whose headings are set apart only by how they look (#1693): chapters in +/// Helvetica-Bold 18, sections in Helvetica-Bold 14, text in Helvetica 11. Page 2's chapter title +/// runs over two lines, its bold font is another subset of the same font, and one section is set +/// with `1 Tf` and a text matrix 14 times larger. Page 3 has two sections on one row. +fn styled_headings_fixture() -> Vec { + let page1 = "BT /FB 18 Tf 72 700 Td (Chapter One) Tj ET BT /FR 11 Tf 72 670 Td (Body text) Tj ET \ + BT /FB 14 Tf 72 640 Td (Section A) Tj ET BT /FR 11 Tf 72 620 Td (More body) Tj ET"; + let page2 = "BT /FS 18 Tf 72 700 Td (Chapter Two) Tj ET BT /FS 18 Tf 72 680 Td (and its subtitle) Tj ET \ + BT /FB 1 Tf 14 0 0 14 72 640 Tm (Section B) Tj ET BT /FR 11 Tf 72 620 Td (Body) Tj ET \ + BT /FB 14 Tf 72 600 Td (Section C) Tj ET"; + // Two sections on one row, the right one drawn first: read from the left. + let page3 = "BT /FB 14 Tf 300 500 Td (Right part) Tj ET BT /FB 14 Tf 72 500 Td (Left part) Tj ET"; + let fonts = "/Font << /FB 6 0 R /FR 7 0 R /FS 8 0 R >>"; + pdfcraft_testkit::Pdf::new() + .obj("<< /Type /Catalog /Pages 2 0 R >>") + .obj(format!("<< /Type /Pages /Kids [3 0 R 4 0 R 10 0 R] /Count 3 /MediaBox [0 0 612 792] /Resources << {fonts} >> >>")) + .obj("<< /Type /Page /Parent 2 0 R /Contents 5 0 R >>") + .obj("<< /Type /Page /Parent 2 0 R /Contents 9 0 R >>") + .stream("", page1) + .obj("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>") + .obj("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>") + .obj("<< /Type /Font /Subtype /Type1 /BaseFont /ABCDEF+Helvetica-Bold /Encoding /WinAnsiEncoding >>") + .stream("", page2) + .obj("<< /Type /Page /Parent 2 0 R /Contents 11 0 R >>") + .stream("", page3) + .build() +} + +/// #1693: the document's text styles, largest first, and bookmarks made from the chosen ones: +/// chapters at level 1, sections nested under the chapter before them. A two-line title is one +/// bookmark; a subset of the same font and a size set by the text matrix count as the style. +/// Each bookmark goes to its heading's top. One undo step; no style, or none in use, is refused. +#[test] +fn bookmarks_from_text_styles_nest_the_headings_and_undo() { + let mut s = Session::new(); + let id = s.open("styled.pdf", None, Arc::new(styled_headings_fixture()), None).unwrap(); + let styles = s.get(id).unwrap().text_styles(); + let listed: Vec<(String, f64, bool, usize, usize)> = + styles.iter().map(|u| (u.style.font.clone(), u.style.size, u.style.bold, u.lines, u.pages)).collect(); + assert_eq!( + listed, + [ + ("Helvetica-Bold".to_string(), 18.0, true, 3, 2), + ("Helvetica-Bold".to_string(), 14.0, true, 5, 3), + ("Helvetica".to_string(), 11.0, false, 3, 2), + ] + ); + assert_eq!(styles[0].sample, "Chapter One"); + let (chapter, section) = (styles[0].style.clone(), styles[1].style.clone()); + s.apply(id, Edit::BookmarksFromTextStyles { levels: vec![chapter.clone(), section.clone()] }).unwrap(); + let outline = s.get(id).unwrap().info.outline.clone(); + assert_eq!( + outline_titles(&outline), + ["Untitled→0[Chapter One→1[Section A→1],Chapter Two and its subtitle→2[Section B→2,Section C→2,Left part→3,Right part→3]]"] + ); + let first = &outline[0].children[0]; + match first.view { + pdfcraft_render::DestView::Xyz { left: None, top: Some(top), zoom: None } => { + assert!((700.0..720.0).contains(&top), "the top of the heading's line: {top}") + } + ref v => panic!("{v:?}"), + } + assert_eq!(s.get(id).unwrap().can_undo(), Some("New bookmarks from text styles")); + s.undo(id).unwrap(); + assert!(s.get(id).unwrap().info.outline.is_empty()); + // One level is enough; the sections are then left out. + s.apply(id, Edit::BookmarksFromTextStyles { levels: vec![chapter] }).unwrap(); + assert_eq!(outline_titles(&s.get(id).unwrap().info.outline), ["Untitled→0[Chapter One→1,Chapter Two and its subtitle→2]"]); + s.undo(id).unwrap(); + // Nothing chosen, or a style no text is in: refused, nothing changes. + let err = s.apply(id, Edit::BookmarksFromTextStyles { levels: vec![] }).unwrap_err(); + assert!(err.to_string().contains("at least one bookmark level"), "{err}"); + let unused = pdfcraft_engine_style(§ion, 30.0); + let err = s.apply(id, Edit::BookmarksFromTextStyles { levels: vec![unused] }).unwrap_err(); + assert!(err.to_string().contains("no text is set in the chosen styles"), "{err}"); + assert!(s.get(id).unwrap().info.outline.is_empty()); +} + +/// CodeRabbit on #4560: a line goes to the first level whose style it is in, so the same style +/// for two levels (or one within the size tolerance of the other) would leave the later level +/// empty. Refused at the edit, for the dialog and automation alike; nothing changes. +#[test] +fn bookmarks_from_text_styles_refuse_one_style_for_two_levels() { + let mut s = Session::new(); + let id = s.open("styled.pdf", None, Arc::new(styled_headings_fixture()), None).unwrap(); + let styles = s.get(id).unwrap().text_styles(); + let (chapter, section) = (styles[0].style.clone(), styles[1].style.clone()); + let near = pdfcraft_engine_style(§ion, section.size + 0.1); + for levels in [vec![chapter.clone(), chapter.clone()], vec![chapter.clone(), section.clone(), near]] { + let n = levels.len(); + let err = s.apply(id, Edit::BookmarksFromTextStyles { levels }).unwrap_err(); + assert!(err.to_string().contains(&format!("levels {} and {n} have the same text style", n - 1)), "{err}"); + assert!(s.get(id).unwrap().info.outline.is_empty()); + } +} + +/// CodeRabbit on #4560: bookmark titles from text styles are the document's text, so a document +/// that forbids copying lists no styles and refuses the edit, as it refuses other extraction. +#[test] +fn bookmarks_from_text_styles_need_copying_permission() { + let mut doc = pdfcraft_cos::Document::open(Arc::new(styled_headings_fixture())).unwrap(); + doc.set_encryption(&pdfcraft_cos::NewEncryption { + algorithm: pdfcraft_cos::Algorithm::Aes256, + user_password: "", + owner_password: "owner", + permissions: -1 ^ 0b1_0000, + encrypt_metadata: true, + seed: [7; 32], + }) + .unwrap(); + let bytes = Arc::new(pdfcraft_cos::write_full(&doc, &Default::default()).unwrap()); + let mut s = Session::new(); + let id = s.open("r.pdf", None, bytes.clone(), None).unwrap(); + assert!(s.get(id).unwrap().allows_assembly() && !s.get(id).unwrap().allows_copying()); + assert!(s.get(id).unwrap().text_styles().is_empty(), "no samples of text that may not be copied"); + let style = text_style_bookmarks::TextStyle { font: "Helvetica-Bold".into(), size: 18.0, bold: true, italic: false }; + let err = s.apply(id, Edit::BookmarksFromTextStyles { levels: vec![style.clone()] }).unwrap_err(); + assert_eq!(err, EditError::NotPermitted("copying text and images")); + let owner = s.open("r.pdf", None, bytes, Some("owner")).unwrap(); + assert!(!s.get(owner).unwrap().text_styles().is_empty()); + assert!(s.apply(owner, Edit::BookmarksFromTextStyles { levels: vec![style] }).is_ok(), "the owner may"); +} + +/// CodeRabbit on #4560: two headings in one style in two columns, the right one a little lower, +/// are two bookmarks, not one heading run on across the columns. +#[test] +fn headings_in_two_columns_are_two_bookmarks() { + let page = "BT /FB 14 Tf 72 700 Td (Left heading) Tj ET BT /FB 14 Tf 320 690 Td (Right heading) Tj ET"; + let bytes = pdfcraft_testkit::Pdf::new() + .obj("<< /Type /Catalog /Pages 2 0 R >>") + .obj("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] /Resources << /Font << /FB 5 0 R >> >> >>") + .obj("<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>") + .stream("", page) + .obj("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>") + .build(); + let mut s = Session::new(); + let id = s.open("cols.pdf", None, Arc::new(bytes), None).unwrap(); + let style = s.get(id).unwrap().text_styles()[0].style.clone(); + s.apply(id, Edit::BookmarksFromTextStyles { levels: vec![style] }).unwrap(); + assert_eq!(outline_titles(&s.get(id).unwrap().info.outline), ["Untitled→0[Left heading→1,Right heading→1]"]); +} + +/// `style` at another size. +fn pdfcraft_engine_style(style: &text_style_bookmarks::TextStyle, size: f64) -> text_style_bookmarks::TextStyle { + text_style_bookmarks::TextStyle { size, ..style.clone() } +} + #[test] fn number_pages_shows_in_the_viewer_and_undoes() { let (mut s, id) = session_with(5); @@ -2170,6 +2425,84 @@ fn passwords_that_encryption_would_make_empty_or_equal_are_refused() { } } +#[test] +fn fresh_sessions_protecting_identical_documents_produce_different_salts() { + // Characterization only: two sessions' salts differ. A per-call RandomState already did + // that before Protect drew from the platform CSPRNG, so this does not prove the source. + // `protect_fails_closed_when_randomness_is_unavailable` is the guard. It does not compile + // against a Protect with no CSPRNG hook, and it fails closed when that hook errors or + // returns zeros. Comparing the stored function with `getrandom::fill` is not reliable: + // one function can have different addresses in different codegen units. + let raw = Arc::new(fixture(1)); + let encrypt_bytes = |saved: Arc>, key: &[u8]| -> Vec { + let doc = pdfcraft_cos::Document::open_with_password(saved, Some("secret")).unwrap(); + let enc = doc.trailer().get(b"Encrypt").map(|o| doc.resolve(o)).unwrap(); + enc.as_dict().unwrap().get(key).map(|o| doc.resolve(o)).unwrap().as_string().unwrap().bytes.clone() + }; + let protect_once = |p: Protection| -> Arc> { + let mut s = Session::new(); + let id = s.open("same.pdf", None, raw.clone(), None).unwrap(); + s.apply(id, Edit::Protect(p)).unwrap(); + s.save_bytes(id).unwrap() + }; + let a = protect_once(protection(Some("secret"), None)); + let b = protect_once(protection(Some("secret"), None)); + let (u_a, u_b) = (encrypt_bytes(a.clone(), b"U"), encrypt_bytes(b.clone(), b"U")); + let (o_a, o_b) = (encrypt_bytes(a.clone(), b"O"), encrypt_bytes(b.clone(), b"O")); + let (ue_a, ue_b) = (encrypt_bytes(a.clone(), b"UE"), encrypt_bytes(b.clone(), b"UE")); + let (oe_a, oe_b) = (encrypt_bytes(a, b"OE"), encrypt_bytes(b, b"OE")); + assert_eq!((u_a.len(), u_b.len(), o_a.len(), o_b.len()), (48, 48, 48, 48)); + assert_ne!(&u_a[32..48], &u_b[32..48], "user validation and key salts must differ across fresh sessions"); + assert_ne!(&o_a[32..48], &o_b[32..48], "owner validation and key salts must differ across fresh sessions"); + assert_ne!(ue_a, ue_b, "wrapped user file keys must differ across fresh sessions"); + assert_ne!(oe_a, oe_b, "wrapped owner file keys must differ across fresh sessions"); + + // Generated owner passwords in R4 (AES-128) must also come from CSPRNG entropy, even when the + // document already carries a fixed /ID and only an open password is set. + let mut with_id = pdfcraft_cos::Document::open(raw).unwrap(); + let fixed_id = pdfcraft_cos::Object::String(pdfcraft_cos::PdfString { bytes: b"0123456789abcdef".to_vec(), hex: true }); + with_id.trailer_mut().set(b"ID".to_vec(), pdfcraft_cos::Object::Array(vec![fixed_id.clone(), fixed_id])); + let raw_with_id = Arc::new(pdfcraft_cos::write_full(&with_id, &Default::default()).unwrap()); + let protect_r4 = || -> Vec { + let mut s = Session::new(); + let id = s.open("same.pdf", None, raw_with_id.clone(), None).unwrap(); + let p = Protection { algorithm: pdfcraft_cos::Algorithm::Aes128, ..protection(Some("secret"), None) }; + s.apply(id, Edit::Protect(p)).unwrap(); + encrypt_bytes(s.save_bytes(id).unwrap(), b"O") + }; + assert_ne!(protect_r4(), protect_r4(), "generated owner passwords must differ across fresh sessions"); +} + +#[test] +fn protect_fails_closed_when_randomness_is_unavailable() { + use std::sync::atomic::{AtomicUsize, Ordering}; + static CALLS: AtomicUsize = AtomicUsize::new(0); + fn fail_rng(_: &mut [u8]) -> Result<(), getrandom::Error> { + Err(getrandom::Error::UNSUPPORTED) + } + fn zero_rng(out: &mut [u8]) -> Result<(), getrandom::Error> { + out.fill(0); + Ok(()) + } + fn fail_second_rng(out: &mut [u8]) -> Result<(), getrandom::Error> { + if CALLS.fetch_add(1, Ordering::SeqCst) == 0 { + out.fill(0xA5); + Ok(()) + } else { + Err(getrandom::Error::UNSUPPORTED) + } + } + CALLS.store(0, Ordering::SeqCst); + for rng in [fail_rng, zero_rng, fail_second_rng] { + let mut doc = pdfcraft_cos::Document::open(Arc::new(fixture(1))).unwrap(); + let mut cx = EditCtx::new(None, 0); + cx.rng = rng; + let err = run_edit(&mut doc, &Edit::Protect(protection(Some("secret"), None)), &mut cx); + assert!(matches!(err, Err(EditError::Protection(_))), "Protect must fail closed when CSPRNG fails: {err:?}"); + assert!(doc.output_handler().is_none(), "document must stay unprotected when CSPRNG fails"); + } +} + /// A one-page form: a text field and a check box (with appearance states). fn form_fixture() -> Vec { let objs = [ @@ -2375,10 +2708,10 @@ fn a_value_its_font_cannot_draw_is_reported_by_the_edit_that_drew_it() { fill(&mut s, "name", FieldValue::Text("张三".into())).unwrap(); let d = s.get(id).unwrap(); assert_eq!(d.form[0].value, ["张三"]); - let n = &d.edit_notices; - assert_eq!(n.len(), 1, "{n:?}"); - assert_eq!((n[0].field.as_deref(), n[0].page, n[0].ch, n[0].redrawn_elsewhere), (Some("name"), 0, '张', true)); - assert!(n[0].to_string().contains("\"张\" (U+5F20)") && n[0].to_string().contains("other PDF viewers redraw"), "{}", n[0]); + let [EditNotice::Undrawn(n)] = d.edit_notices.as_slice() else { panic!("{:?}", d.edit_notices) }; + assert_eq!((n.field.as_deref(), n.page, n.ch, n.redrawn_elsewhere), (Some("name"), 0, '张', true)); + let said = d.edit_notices[0].to_string(); + assert!(said.contains("\"张\" (U+5F20)") && said.contains("other PDF viewers redraw"), "{said}"); fill(&mut s, "ok", FieldValue::Check(true)).unwrap(); assert!(s.get(id).unwrap().edit_notices.is_empty(), "an unrelated edit reports nothing"); fill(&mut s, "name", FieldValue::Text("李四".into())).unwrap(); @@ -3002,6 +3335,98 @@ fn headers_footers_watermarks_and_backgrounds_show_update_and_remove() { assert_eq!(s.get(id).unwrap().can_undo(), Some("Remove background")); } +/// One 100 × 100 page under a background (`/PCMark /Background`), its own content painting a +/// white backdrop first. With `copy`, that content is a backdrop copy whose tag is `copy`. +fn backdrop_pdf(copy: Option<&str>) -> Vec { + let bg = "q 1 0 0 rg 0 0 100 100 re f Q"; + let (tag, fill) = match copy { + Some(tag) => (format!("/PCBackdrop {tag} "), "n"), + None => (String::new(), "f"), + }; + let content = format!("1 g 0 0 100 100 re {fill} 0 g BT /F1 12 Tf 10 50 Td (Hi) Tj ET"); + format!( + "%PDF-1.7\n\ + 1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents [4 0 R 5 0 R] /Resources << /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> >> >> endobj\n\ + 4 0 obj << /PCMark /Background /Length {} >> stream\n{bg}\nendstream endobj\n\ + 5 0 obj << {tag}/Length {} >> stream\n{content}\nendstream endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF", + bg.len(), + content.len() + ) + .into_bytes() +} + +/// Remove background says which pages keep their own white backdrop off (it couldn't be turned +/// back on safely), and says nothing when every backdrop comes back. +#[test] +fn remove_background_names_the_pages_whose_own_backdrop_stays_off() { + let mut s = Session::new(); + // A tag whose hash doesn't match the bytes before the fill: something changed them since. + let tag = format!("<< /Index 2 /Op (f) /Before <{}> >>", "00".repeat(32)); + let id = s.open("a.pdf", None, Arc::new(backdrop_pdf(Some(&tag))), None).unwrap(); + s.apply(id, Edit::RemoveMarks { kind: MarkKind::Background }).unwrap(); + let d = s.get(id).unwrap(); + assert_eq!(d.edit_notices, [EditNotice::BackdropsLeftOff { pages: vec![0] }]); + let said = d.edit_notices[0].to_string(); + assert!(said.starts_with("page 1 changed after the background was added"), "{said}"); + // An unrelated edit after it says nothing. + s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap(); + assert!(s.get(id).unwrap().edit_notices.is_empty()); + + // Added and removed here: the backdrop comes back, and nothing is said. + let id = s.open("b.pdf", None, Arc::new(backdrop_pdf(None)), None).unwrap(); + s.apply(id, Edit::RemoveMarks { kind: MarkKind::Background }).unwrap(); + let background = Background { color: [1.0, 0.0, 0.0], ..Background::default() }; + s.apply(id, Edit::AddBackground { pages: vec![0], settings: background, replace: false, file: None }).unwrap(); + assert_eq!(s.get(id).unwrap().page_backdrops(&[0]), [None], "replaced"); + s.apply(id, Edit::RemoveMarks { kind: MarkKind::Background }).unwrap(); + assert!(s.get(id).unwrap().edit_notices.is_empty()); + assert_eq!(s.get(id).unwrap().page_backdrops(&[0]), [Some(Backdrop::Fill)], "turned back on"); + assert_eq!(page_runs(&[0, 1, 2, 5, 7, 8]), "1-3, 6, 8-9"); + assert_eq!(page_runs(&[]), ""); +} + +/// Update previews its marks on the page without the ones it replaces: a yellow background +/// updated to blue isn't previewed as blue over yellow, nor a new watermark over the old one. +/// The other kinds of mark and the page's own content stay, every page is prepared at once (the +/// dialog steps through them), a document without that kind has nothing to prepare, and +/// preparing changes nothing in the document. +#[test] +fn update_previews_on_the_page_without_the_marks_it_replaces() { + let (mut s, id) = session_with(2); + let yellow = Background { color: [1.0, 1.0, 0.0], ..Background::default() }; + s.apply(id, Edit::AddBackground { pages: vec![0, 1], settings: yellow, replace: false, file: None }).unwrap(); + let red = Watermark { text: "SECRET".into(), color: [1.0, 0.0, 0.0], opacity: 1.0, ..Watermark::default() }; + s.apply(id, Edit::AddWatermark { pages: vec![0], settings: red, replace: false, file: None }).unwrap(); + let doc = s.get(id).unwrap(); + let (generation, dirty) = (doc.edit_generation(), doc.dirty); + let px = |p: &pdfcraft_render::RenderedPage, x: u32, y: u32| -> [u8; 3] { + p.rgba[(((300 - y) * p.width + x) * 4) as usize..][..3].try_into().unwrap() + }; + let reds = |p: &pdfcraft_render::RenderedPage| p.rgba.as_chunks::<4>().0.iter().filter(|[r, g, b, _]| *r > 200 && *g < 80 && *b < 80).count(); + let dark = |p: &pdfcraft_render::RenderedPage| p.rgba.as_chunks::<4>().0.iter().filter(|[r, g, b, _]| *r < 100 && *g < 100 && *b < 100).count(); + assert_eq!(pixel(&s, id, 0, 5, 5)[..3], [255, 255, 0], "the page has the yellow background"); + + let mut unmarked = doc.without_marks(MarkKind::Background).unwrap().expect("the pages have a background"); + let page = unmarked.render_page(0, 1.0).unwrap(); + assert_eq!(px(&page, 5, 5), [255, 255, 255], "without the background the page is white"); + assert!(reds(&page) > 50, "the watermark stays"); + assert!(dark(&page) > 10, "the page text stays"); + + let page = doc.without_marks(MarkKind::Watermark).unwrap().expect("page 1 has a watermark").render_page(0, 1.0).unwrap(); + assert_eq!(reds(&page), 0, "without the watermark"); + assert_eq!(px(&page, 5, 5), [255, 255, 0], "the background stays"); + + assert_eq!(px(&unmarked.render_page(1, 1.0).unwrap(), 5, 5), [255, 255, 255], "made once, for every page: page 2 is white too"); + assert_eq!(unmarked.parser_builds(), 1, "parsed once for both pages"); + assert!(doc.without_marks(MarkKind::HeaderFooter).unwrap().is_none(), "the document has no header"); + assert!(unmarked.render_page(9, 1.0).is_err(), "no page 10"); + let doc = s.get(id).unwrap(); + assert_eq!((doc.edit_generation(), doc.dirty, doc.can_undo()), (generation, dirty, Some("Add watermark")), "the document is unchanged"); +} + #[test] fn marker_indexes_match_after_open_and_save_when_requested() { let (mut source, source_id) = session_with(1); @@ -3068,7 +3493,12 @@ fn secondary_indexes_load_once_and_follow_edit_scopes() { assert!(doc.links().is_empty()); assert_eq!(secondary_scan_counts(), (1, 1), "repeated reads reuse the link list"); } + metadata_and_comment_edits_keep_the_indexes(&mut session, id); + link_edits_rebuild_only_the_link_list(&mut session, id); + content_and_marker_edits_rebuild_the_markers(&mut session, id); +} +fn metadata_and_comment_edits_keep_the_indexes(session: &mut Session, id: DocId) { session.apply(id, Edit::SetInfo { key: "Title".into(), value: "Changed".into() }).unwrap(); assert!(session.get(id).unwrap().links().is_empty()); assert!(session.get(id).unwrap().marks().is_empty()); @@ -3077,7 +3507,9 @@ fn secondary_indexes_load_once_and_follow_edit_scopes() { session.apply(id, rect_comment(0, [20.0, 20.0, 60.0, 60.0])).unwrap(); assert!(session.get(id).unwrap().links().is_empty()); assert_eq!(secondary_scan_counts(), (1, 2), "comment edits invalidate the annotation-position link index"); +} +fn link_edits_rebuild_only_the_link_list(session: &mut Session, id: DocId) { session .apply( id, @@ -3101,7 +3533,9 @@ fn secondary_indexes_load_once_and_follow_edit_scopes() { session.apply(id, Edit::DeleteAnnotation { page: 0, index: 0 }).unwrap(); assert_eq!(session.get(id).unwrap().links()[0].index, 0, "deleting an earlier annotation shifts link indexes"); assert_eq!(secondary_scan_counts(), (1, 6), "comment deletion rebuilds annotation-position indexes"); +} +fn content_and_marker_edits_rebuild_the_markers(session: &mut Session, id: DocId) { session .apply(id, Edit::AddText { page: 0, text: AddedText { rect: [20.0, 250.0, 100.0, 280.0], text: "Added".into(), ..Default::default() } }) .unwrap(); @@ -3169,13 +3603,73 @@ fn create_and_reduce() { ) .unwrap(); let full = s2.save_full_bytes(id2).unwrap(); - let (reduced, _) = s2.reduced_bytes(id2).unwrap(); + let (reduced, ..) = s2.reduced_bytes(id2).unwrap(); assert!(reduced.len() <= full.len(), "{} > {}", reduced.len(), full.len()); let mut s3 = Session::new(); let r = s3.open("r.pdf", None, reduced, None).unwrap(); assert_eq!(page_texts(&s3, r), ["Page 1", "Page 1"]); } +/// Discarding a category that holds nothing discards nothing; it used to end the whole +/// optimization with an error about redaction marks. +#[test] +fn optimize_discarding_an_empty_category_still_runs() { + let (s, id) = session_with(2); + let (bytes, report) = s.optimized_bytes(id, &optimize::Settings::default(), &[crate::Hidden::Bookmarks, crate::Hidden::Comments]).unwrap(); + assert!(bytes.starts_with(b"%PDF-")); + assert!(report.discarded.is_empty(), "{:?}", report.discarded); + // An outline root with no entries is no bookmarks: it stays, as Remove Hidden Information, + // which refuses then, leaves it. + let outline = pdfcraft_testkit::Pdf::new() + .obj("<< /Type /Catalog /Pages 2 0 R /Outlines 4 0 R /PageMode /UseOutlines >>") + .obj("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>") + .obj("<< /Type /Page /Parent 2 0 R >>") + .obj("<< /Type /Outlines /Count 0 >>") + .build(); + let mut s = Session::new(); + let id = s.open("outline.pdf", None, Arc::new(outline), None).unwrap(); + let (bytes, report) = s.optimized_bytes(id, &optimize::Settings::default(), &[crate::Hidden::Bookmarks]).unwrap(); + assert!(report.discarded.is_empty(), "{:?}", report.discarded); + let saved = pdfcraft_cos::Document::open(bytes).unwrap(); + let catalog = saved.get(saved.root().unwrap()).as_dict().cloned().unwrap(); + assert!(catalog.contains(b"Outlines") && catalog.contains(b"PageMode"), "{catalog:?}"); + // Beside an empty tick, one that holds something is discarded, and only that one. + let noted = pdfcraft_testkit::Pdf::new() + .obj("<< /Type /Catalog /Pages 2 0 R >>") + .obj("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>") + .obj("<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>") + .obj("<< /Type /Annot /Subtype /Text /Rect [20 20 40 40] /Contents (internal note) >>") + .build(); + let mut s = Session::new(); + let id = s.open("noted.pdf", None, Arc::new(noted), None).unwrap(); + let (_, report) = s.optimized_bytes(id, &optimize::Settings::default(), &[crate::Hidden::Bookmarks, crate::Hidden::Comments]).unwrap(); + assert_eq!(report.discarded, vec![(crate::Hidden::Comments, 1)]); + // A field whose appearance draws text invisibly: flattening it brings that text onto the page, + // which no scan before counts as hidden, and discarding hidden text takes it too. + let form = pdfcraft_testkit::Pdf::new() + .obj("<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] /DA (/F1 0 Tf 0 g) >> >>") + .obj("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>") + .obj("<< /Type /Page /Parent 2 0 R /Annots [4 0 R] /Resources << /Font << /F1 6 0 R >> >> /Contents 7 0 R >>") + .obj("<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /V (shown) /Rect [100 100 300 130] /P 3 0 R /F 4 /AP << /N 5 0 R >> >>") + .stream( + "/Type /XObject /Subtype /Form /BBox [0 0 200 30] /Resources << /Font << /F1 6 0 R >> >>", + "/Tx BMC BT /F1 12 Tf 2 10 Td (shown) Tj ET BT /F1 12 Tf 3 Tr 2 20 Td (HIDDENSECRET) Tj ET EMC", + ) + .obj("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>") + .stream("", "BT /F1 12 Tf 72 700 Td (Page text) Tj ET") + .build(); + let mut s = Session::new(); + let id = s.open("form.pdf", None, Arc::new(form), None).unwrap(); + let (bytes, _) = s.optimized_bytes(id, &optimize::Settings::default(), &[crate::Hidden::FormFields, crate::Hidden::HiddenText]).unwrap(); + let saved = pdfcraft_cos::Document::open(bytes).unwrap(); + for (r, object) in saved.scan_objects() { + if let pdfcraft_cos::Object::Stream(st) = &*object { + let data = st.decoded().unwrap_or_default(); + assert!(!data.windows(12).any(|w| w == b"HIDDENSECRET"), "the hidden text stays in {r:?}"); + } + } +} + #[test] fn an_optimize_job_reports_its_stages_and_can_be_cancelled() { use crate::optimizer::OptimizeStage; @@ -3236,6 +3730,24 @@ fn comment_text(s: &Session, id: DocId) -> Vec { t } +/// A stamp whose reply is locked can still be dragged, so its layers are still prepared: the page +/// beneath is drawn on a copy, where locks don't count. Deleting the stamp is what they refuse. +#[test] +fn a_stamp_with_a_locked_reply_still_has_its_drag_layers() { + let (mut s, id) = session_with(1); + s.apply(id, fill_item(Shape::Mark { rect: [30.0, 60.0, 130.0, 66.0], mark: FillMark::Line }, "")).unwrap(); + s.apply(id, Edit::ReplyToAnnotation { page: 0, index: 0, text: "Approved".into(), author: "Bob".into() }).unwrap(); + let reply = s.get(id).unwrap().info.annotations.iter().find(|a| a.parent == Some((0, 0))).expect("the reply").index; + s.apply(id, Edit::LockAnnotation { page: 0, index: reply, locked: true }).unwrap(); + let doc = s.get(id).unwrap(); + let background = doc.stamp_preview(0, 0).unwrap().expect("the line is a stamp").render_background(1.0).unwrap(); + let px = |p: &pdfcraft_render::RenderedPage, x: u32, y: u32| -> [u8; 4] { + p.rgba[(((300 - y) * p.width + x) * 4) as usize..][..4].try_into().unwrap() + }; + assert!((30..=130).all(|x| px(&background, x, 63) == [255, 255, 255, 255]), "the page beneath has no line"); + assert!(s.apply(id, Edit::DeleteAnnotation { page: 0, index: 0 }).is_err(), "the locked reply keeps the stamp"); +} + /// A dragged stamp's layers: the page without it (its text and other comments kept) and the /// stamp alone on a transparent page. Preparing them changes nothing in the document. #[test] @@ -3290,6 +3802,44 @@ trailer << /Root 1 0 R >> assert!(doc.stamp_preview(0, 1).unwrap().is_none(), "a stamp without an appearance"); } +/// A moved comment's layer: the comment alone on a transparent page (no page text, no other +/// comment), drawn as on screen, another app's comment without an appearance too. Preparing it +/// changes nothing in the document. +#[test] +fn comment_layer_draws_the_comment_alone() { + let (mut s, id) = session_with(1); + s.apply(id, rect_comment(0, [140.0, 40.0, 190.0, 90.0])).unwrap(); + s.apply(id, rect_comment(0, [20.0, 20.0, 60.0, 60.0])).unwrap(); + let doc = s.get(id).unwrap(); + let (generation, dirty) = (doc.edit_generation(), doc.dirty); + let layer = doc.comment_layer(0, &[0]).unwrap().expect("a rectangle").render(1.0).unwrap(); + let px = |p: &pdfcraft_render::RenderedPage, x: u32, y: u32| -> [u8; 4] { + p.rgba[(((300 - y) * p.width + x) * 4) as usize..][..4].try_into().unwrap() + }; + assert_eq!(px(&layer, 165, 65), [255, 0, 0, 255], "the rectangle is drawn"); + let near = |x: u32, y: u32| (137..=193).contains(&x) && (37..=93).contains(&y); + let stray: Vec<(u32, u32)> = + (1..300).flat_map(|y| (0..200).map(move |x| (x, y))).filter(|&(x, y)| !near(x, y) && px(&layer, x, y) != [0, 0, 0, 0]).collect(); + assert!(stray.is_empty(), "only the rectangle is on its layer: {:?}", &stray[..stray.len().min(8)]); + assert!(doc.comment_layer(0, &[9]).unwrap().is_none(), "no such comment"); + let both = doc.comment_layer(0, &[0, 1]).unwrap().expect("both").render(1.0).unwrap(); + assert_eq!((px(&both, 165, 65), px(&both, 40, 40)), ([255, 0, 0, 255], [255, 0, 0, 255]), "moved together, drawn together"); + let doc = s.get(id).unwrap(); + assert_eq!((doc.edit_generation(), doc.dirty, doc.can_undo()), (generation, dirty, Some("Add rectangle")), "the document is unchanged"); + + let bare = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /Annots [4 0 R] >> endobj +4 0 obj << /Type /Annot /Subtype /Square /Rect [140 40 190 90] /C [1 0 0] >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + let mut s = Session::new(); + let id = s.open("bare.pdf", None, Arc::new(bare.to_vec()), None).unwrap(); + let layer = s.get(id).unwrap().comment_layer(0, &[0]).unwrap().expect("drawn as on screen").render(1.0).unwrap(); + assert!((138..=142).any(|x| px(&layer, x, 65)[..3] == [255, 0, 0]), "the square without an appearance is drawn"); +} + #[test] fn flattening_fill_and_sign_keeps_other_comments_fields_and_undoes() { let (mut s, id) = session_with(1); @@ -3410,6 +3960,70 @@ fn pages_export_as_png_jpeg_and_tiff() { assert_eq!((compression, predictor), (5, 1), "LZW, without a predictor"); } +/// Export ▸ Image draws several pages at once: each file is the one exporting that page alone +/// writes, handed over in the order asked for, whatever the number of pages drawn at once. +#[test] +fn pages_exported_at_once_are_the_files_each_page_exports_alone_in_order() { + use crate::export::{Exporter, ImageFormat}; + let (s, id) = session_with(12); + let pages = [3, 0, 11, 5, 5, 7, 1, 2, 10, 9, 8, 6, 4]; + for format in [ImageFormat::Png, ImageFormat::Jpeg { quality: 85 }, ImageFormat::Tiff] { + let alone: Vec<(usize, Vec)> = pages.iter().map(|&p| (p, Exporter::new(s.get(id).unwrap()).image(p, 96.0, format).unwrap())).collect(); + for workers in [1, 2, 4, 8] { + let mut got = Vec::new(); + let mut ex = Exporter::new(s.get(id).unwrap()); + ex.images_on(workers, &pages, 96.0, format, |p, img| { + got.push((p, img)); + Ok(()) + }) + .unwrap(); + assert!(got == alone, "{} with {workers} workers", format.label()); + } + let mut got = Vec::new(); + Exporter::new(s.get(id).unwrap()) + .images(&pages, 96.0, format, |p, img| { + got.push((p, img)); + Ok(()) + }) + .unwrap(); + assert!(got == alone, "{}", format.label()); + } +} + +/// A page that can't be exported, or a file that can't be written, stops the export there with +/// its error: every page before it is handed over, none after it. +#[test] +fn exporting_pages_at_once_stops_at_the_first_error() { + use crate::export::{Exporter, ImageFormat}; + let (s, id) = session_with(6); + for workers in [1, 4] { + let mut ex = Exporter::new(s.get(id).unwrap()); + let mut got = Vec::new(); + let result = ex.images_on(workers, &[0, 1, 99, 2, 3], 72.0, ImageFormat::Png, |p, _| { + got.push(p); + Ok(()) + }); + assert_eq!(result, Err("page 100 does not exist".to_string()), "{workers} workers"); + assert_eq!(got, [0, 1], "{workers} workers"); + + let mut got = Vec::new(); + let result = ex.images_on(workers, &[0, 1, 2, 3, 4, 5], 72.0, ImageFormat::Png, |p, _| { + got.push(p); + if p == 2 { Err("disk full".to_string()) } else { Ok(()) } + }); + assert_eq!(result, Err("disk full".to_string()), "{workers} workers"); + assert_eq!(got, [0, 1, 2], "{workers} workers"); + // The exporter is still usable afterwards. + assert!(ex.image(5, 72.0, ImageFormat::Png).is_ok()); + } + // A panicking sink stops the workers rather than leaving them waiting for it. + let mut ex = Exporter::new(s.get(id).unwrap()); + let panicked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + ex.images_on(4, &[0, 1, 2, 3, 4, 5], 72.0, ImageFormat::Png, |p, _| if p == 1 { panic!("sink") } else { Ok(()) }) + })); + assert!(panicked.is_err()); +} + /// A TIFF's resolution unit (2 = inch) and its horizontal and vertical resolution. fn tiff_resolution(bytes: &[u8]) -> (u32, f64, f64) { use tiff::{decoder::ifd::Value, tags::Tag}; @@ -3490,6 +4104,7 @@ fn pasted_added_content_keeps_its_style_and_picture() { size: 12.0, color: [0.8, 0.1, 0.2], align: TextAlign::Center, + aligns: Vec::new(), char_spacing: 0.0, }; s.apply(id, Edit::AddText { page: 0, text: text.clone() }).unwrap(); @@ -4735,6 +5350,8 @@ fn scripted_form_with(helpers: &str) -> Vec { out } +/// AcroForm field scripts validate, calculate and format through edits, and a button script's +/// changes, messages and requests come back. #[test] fn form_javascript_validates_calculates_and_formats() { let mut s = Session::new().with_clock(|| 1_700_000_000); @@ -4753,7 +5370,7 @@ fn form_javascript_validates_calculates_and_formats() { // A button script changes fields in one undoable step, and asks for a page. let o = s.run_javascript(id, "getField('price').value = 3; getField('total').readonly = true; app.alert('Done'); this.print();", Some("go")).unwrap(); - assert_eq!(o.alerts, ["Done"]); + assert_eq!(o.alerts, [crate::js::Message::plain("Done")]); assert_eq!(o.requests, [js::Request::Print]); assert_eq!(value(&s, "total"), ["12"], "recalculated after the change"); assert!(s.get(id).unwrap().form.iter().find(|f| f.name == "total").unwrap().read_only()); @@ -4817,17 +5434,17 @@ fn active_xfa_does_not_run_pdf_container_scripts_but_keeps_requested_alerts() { assert!(s.take_js_output(id).alerts.is_empty()); let out = s.run_javascript(id, "app.alert('Console message');", None).unwrap(); assert!(out.error.is_none(), "{out:?}"); - assert_eq!(out.alerts, ["Console message"]); + assert_eq!(out.alerts, [js::Message::plain("Console message")]); assert_eq!(s.get(id).unwrap().document_scripts(), [("viewerFallback".into(), fallback.into())]); let out = s.run_javascript(id, "", Some("hello")).unwrap(); assert!(out.error.is_none(), "{out:?}"); - assert_eq!(out.alerts, ["Hello 2"]); + assert_eq!(out.alerts, [js::Message::plain("Hello 2")]); assert_eq!(out.requests, [js::Request::Print]); // The same context rule applies to format/validate/calculate through JsRunner. s.apply(id, Edit::SetFieldScript { name: "qty".into(), event: "validate".into(), script: Some("app.alert('Field message');".into()) }).unwrap(); s.take_js_output(id); s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("3".into()) }).unwrap(); - assert_eq!(s.take_js_output(id).alerts, ["Field message"]); + assert_eq!(s.take_js_output(id).alerts, [js::Message::plain("Field message")]); // Saved XFA keeps its container scripts for inspection, but does not execute them. let saved = s.get(id).unwrap().bytes.clone(); let reopened = s.open("saved.pdf", None, saved, None).unwrap(); @@ -4852,7 +5469,7 @@ fn active_xfa_without_template_scripts_does_not_initialize_pdf_fallback_scripts( s.apply(id, Edit::SetDocumentScript { name: "fallback".into(), script: Some("throw new Error('PDF fallback ran');".into()) }).unwrap(); let out = s.run_javascript(id, "app.alert('Console message');", None).unwrap(); assert!(out.error.is_none(), "{out:?}"); - assert_eq!(out.alerts, ["Console message"]); + assert_eq!(out.alerts, [js::Message::plain("Console message")]); } #[test] @@ -4869,7 +5486,7 @@ fn inactive_xfa_and_ordinary_pdfs_keep_document_helpers_and_alerts() { let out = pdfcraft_js::run("twice(3)", &pdfcraft_js::Event::doc("Console"), &runner.doc, &[], &runner.doc_scripts, Default::default()); assert!(out.error.is_none(), "{out:?}"); assert_eq!(out.result.as_deref(), Some("6")); - assert_eq!(out.alerts, ["PDF message"]); + assert_eq!(out.alerts, [js::Message::plain("PDF message")]); } } @@ -5635,24 +6252,27 @@ fn a_static_xfa_form_takes_many_data_values_as_one_by_one() { /// Opening a static XFA form whose datasets fill thousands of fields took minutes: each value /// read the whole form again (twice), so the time grew with the square of the fields. Eight /// times the fields now takes at most about eight times as long (it took about fifty). +/// +/// Each round opens the small form and then the large one, so a busy spell on a shared machine +/// slows both, and the best round's ratio is checked. Timing every small opening before the +/// large ones let such a spell fall on the large ones alone. #[test] fn opening_a_static_xfa_form_grows_linearly_with_its_data_values() { - let time = |n: usize| { - let bytes = Arc::new(pdfcraft_xfa::fixtures::static_shell_many(n)); - (0..3) - .map(|_| { - let mut s = Session::new(); - let start = std::time::Instant::now(); - s.open("many.pdf", None, bytes.clone(), None).expect("opens"); - start.elapsed() - }) - .min() - .unwrap_or_default() + let small = Arc::new(pdfcraft_xfa::fixtures::static_shell_many(100)); + let large = Arc::new(pdfcraft_xfa::fixtures::static_shell_many(800)); + let time = |bytes: &Arc>| { + let mut s = Session::new(); + let start = std::time::Instant::now(); + s.open("many.pdf", None, bytes.clone(), None).expect("opens"); + start.elapsed() }; - let small = time(100); - let large = time(800); - let ratio = large.as_secs_f64() / small.as_secs_f64().max(1e-6); - assert!(ratio < 16.0, "100 fields: {small:?}, 800 fields: {large:?} ({ratio:.1}x)"); + // The first openings also pay for what the process sets up once. + time(&small); + time(&large); + let rounds: Vec<_> = (0..5).map(|_| (time(&small), time(&large))).collect(); + let ratio = |(s, l): &(std::time::Duration, std::time::Duration)| l.as_secs_f64() / s.as_secs_f64().max(1e-6); + let best = rounds.iter().map(ratio).fold(f64::INFINITY, f64::min); + assert!(best < 16.0, "100 and 800 fields, by round: {rounds:?} (best {best:.1}x)"); } /// A date goes into the XFA data in the field's data picture (``), as other @@ -6177,6 +6797,8 @@ fn repeated_xfa_print_buttons_run_their_click_script() { } } +/// An XFA form's scripts initialize, calculate, validate, show subforms and add and remove rows, +/// with undo and save/reopen. #[test] fn xfa_scripts_initialize_calculate_validate_toggle_and_add_rows() { let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template())); @@ -6201,7 +6823,7 @@ fn xfa_scripts_initialize_calculate_validate_toggle_and_add_rows() { // A failing validate script shows its message; the value stays, as in Acrobat. s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("500".into()) }).unwrap(); let out = s.take_js_output(id); - assert!(out.alerts.iter().any(|a| a == "Quantity must be 100 or less"), "{out:?}"); + assert!(out.alerts.iter().any(|a| a.text == "Quantity must be 100 or less"), "{out:?}"); assert_eq!(value(&s, "total"), vec!["2500".to_string()]); // A button adds a row: new fields, same page, one undo step. let before = names(&s).len(); @@ -6216,7 +6838,7 @@ fn xfa_scripts_initialize_calculate_validate_toggle_and_add_rows() { assert_eq!(value(&s, "grand"), vec!["42".to_string()], "{:?}", s.take_js_output(id)); // Message boxes reach the viewer. let o = s.run_javascript(id, "", Some("hello")).unwrap(); - assert_eq!(o.alerts, vec!["Hello 500".to_string()]); + assert_eq!(o.alerts, vec![crate::js::Message::plain("Hello 500")]); // The check box shows the hidden subform; undo hides it again. s.apply(id, Edit::SetFieldValue { name: "more".into(), value: FieldValue::Check(true) }).unwrap(); assert!(names(&s).iter().any(|n| n == "note"), "{:?} {:?}", names(&s), s.take_js_output(id)); @@ -6231,7 +6853,7 @@ fn xfa_scripts_initialize_calculate_validate_toggle_and_add_rows() { // FormCalc scripts run too. let o = s.run_javascript(id, "", Some("legacy")).unwrap(); assert_eq!(o.error, None); - assert_eq!(o.alerts, vec!["FormCalc".to_string()]); + assert_eq!(o.alerts, vec![crate::js::Message::plain("FormCalc")]); // Saved and reopened: the visible subform, the values and the rows are what they were. let saved = s.save_bytes(id).unwrap(); let id2 = s.open("again.pdf", None, saved, None).unwrap(); @@ -6452,6 +7074,7 @@ fn an_xfa_form_converts_to_a_static_form_that_keeps_its_fields() { assert_eq!(value(&s, id, "total"), vec!["15".to_string()]); } +/// A dynamic XFA form scripted in FormCalc runs as the JavaScript one does. #[test] fn dynamic_xfa_forms_run_formcalc_scripts() { let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::formcalc_template())); @@ -6470,7 +7093,7 @@ fn dynamic_xfa_forms_run_formcalc_scripts() { assert_eq!(value(&s, "words"), vec!["Thirty-Five".to_string()]); s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("500".into()) }).unwrap(); let out = s.take_js_output(id); - assert!(out.alerts.iter().any(|a| a == "Quantity must be 100 or less"), "{out:?}"); + assert!(out.alerts.iter().any(|a| a.text == "Quantity must be 100 or less"), "{out:?}"); assert_eq!(value(&s, "qty"), vec!["500".to_string()]); // Buttons: a row is added, summed and removed; the message box reaches the viewer. let before = names(&s).len(); @@ -6481,7 +7104,7 @@ fn dynamic_xfa_forms_run_formcalc_scripts() { s.apply(id, Edit::SetFieldValue { name: "amount_2".into(), value: FieldValue::Text("32".into()) }).unwrap(); assert_eq!(value(&s, "grand"), vec!["42".to_string()], "{:?}", s.take_js_output(id)); let o = s.run_javascript(id, "", Some("hello")).unwrap(); - assert_eq!(o.alerts, vec!["Hello 500".to_string()]); + assert_eq!(o.alerts, vec![crate::js::Message::plain("Hello 500")]); s.apply(id, Edit::SetFieldValue { name: "more".into(), value: FieldValue::Check(true) }).unwrap(); assert!(names(&s).iter().any(|n| n == "note"), "{:?} {:?}", names(&s), s.take_js_output(id)); s.run_javascript(id, "", Some("removeRow")).unwrap(); @@ -6616,6 +7239,8 @@ fn datasets_streams(bytes: &Arc>) -> usize { .count() } +/// Hostile scripts run on open are merged, capped, kept from printing, saving and opening links, +/// and noted. #[test] fn hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted() { // On open: a calculate setting its own value 50 000 times, an initialize script showing a @@ -6647,6 +7272,36 @@ fn hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted() { assert!(out.console.iter().any(|c| c.contains("only print when a button is clicked")), "{:?}", out.console); } +/// An XFA button's message boxes reach the viewer with their titles, icons and buttons, from +/// JavaScript (`xfa.host.messageBox`, `app.alert` in its own order) and FormCalc alike, every +/// one of them and in order. +#[test] +fn xfa_message_boxes_keep_their_title_icon_and_buttons() { + use crate::js::{Message, MessageButtons, MessageIcon}; + let tpl = pdfcraft_xfa::fixtures::scripted_template() + .replace( + r#"xfa.host.messageBox("Hello " + qty.rawValue);"#, + r#"xfa.host.messageBox("Clear the rows?", "Order form", 2, 2); app.alert("Saved", 3, 0, "Order form");"#, + ) + .replace(r#"$host.messageBox("FormCalc")"#, r#"$host.messageBox("Too many", "Limits", 1, 1)"#); + let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&tpl)); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("boxes.pdf", None, bytes, None).expect("opens"); + let o = s.run_javascript(id, "", Some("hello")).unwrap(); + assert_eq!(o.error, None); + let titled = |text: &str, title: &str, icon, buttons| Message { text: text.into(), title: Some(title.into()), icon, buttons }; + assert_eq!( + o.alerts, + vec![ + titled("Clear the rows?", "Order form", MessageIcon::Question, MessageButtons::YesNo), + titled("Saved", "Order form", MessageIcon::Status, MessageButtons::Ok), + ] + ); + let o = s.run_javascript(id, "", Some("legacy")).unwrap(); + assert_eq!(o.error, None); + assert_eq!(o.alerts, vec![titled("Too many", "Limits", MessageIcon::Warning, MessageButtons::OkCancel)]); +} + #[test] fn runaway_xfa_calculations_at_open_end_quickly_with_a_report() { // Nested loops in one frame stop at the loop limit; loops in nested calls (that limit is per @@ -7372,6 +8027,7 @@ fn content_edit_keeps_pages_annotations_fields_and_links() { size: 12.0, color: [0.0, 0.0, 0.0], align: TextAlign::Left, + aligns: Vec::new(), char_spacing: 0.0, }; session.apply(id, Edit::AddText { page: 1, text }).unwrap(); @@ -7412,6 +8068,7 @@ fn content_refresh_lists_match_the_storages_an_edit_touches() { size: 12.0, color: [0.0, 0.0, 0.0], align: TextAlign::Left, + aligns: Vec::new(), char_spacing: 0.0, }, }, @@ -7541,6 +8198,13 @@ fn copy_image_finds_the_image_under_a_point_and_decodes_it_at_full_size() { assert_eq!((w, h), (3, 2)); assert_eq!(rgba, px.into_raw()); assert!(d.page_image_rgba(0, 1).is_err()); + + // When the image is cropped, page_image_at only hit-tests the visible cropped region: + let crop_keep = [r[0] + (r[2] - r[0]) * 0.25, r[1] + (r[3] - r[1]) * 0.25, r[0] + (r[2] - r[0]) * 0.75, r[1] + (r[3] - r[1]) * 0.75]; + s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Crop(crop_keep) }).unwrap(); + let d = s.get(id).unwrap(); + assert_eq!(d.page_image_at(0, [(r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0]), Some(0)); + assert_eq!(d.page_image_at(0, [r[0] + 0.1, r[1] + 0.1]), None, "cropped-away corner must not hit-test positive"); } /// Copy Image: a JPEG drawn through grouped Form artwork (as Word, InDesign and scanners write @@ -7593,14 +8257,138 @@ fn copy_image_finds_a_picture_inside_form_artwork_under_text() { assert!(d.page_image_copy_file(0, 1).is_err()); } -/// `fixture(2)` with the catalog pointing at a page tree object the file does not have (#1656). -fn fixture_without_page_tree() -> Vec { - let bytes = fixture(2); - let catalog = b"<< /Type /Catalog /Pages 2 0 R >>"; - let at = bytes.windows(catalog.len()).position(|w| w == catalog).unwrap(); - let mut out = bytes.clone(); - out[at..at + catalog.len()].copy_from_slice(b"<< /Type /Catalog /Pages 9 0 R >>"); - out +/// Copy Image reads a JBIG2 image's `/JBIG2Globals`: here they hold the page information, and the +/// image's own stream only an extension segment decoders skip, so it has a picture only through +/// them. Its default pixel is black. +#[test] +fn copy_image_reads_the_globals_of_a_jbig2_picture() { + let mut globals = vec![0, 0, 0, 0, 48, 0, 1, 0, 0, 0, 19]; + globals.extend_from_slice(&16u32.to_be_bytes()); + globals.extend_from_slice(&2u32.to_be_bytes()); + globals.extend_from_slice(&[0, 0, 0, 0, 0, 0, 0, 0, 4, 0, 0]); // resolution, flags, striping + let image = [0, 0, 0, 1, 62, 0, 1, 0, 0, 0, 0]; + let stream = |dict: &str, data: &[u8]| { + let mut o = format!("<< {dict} /Length {} >>\nstream\n", data.len()).into_bytes(); + o.extend_from_slice(data); + o.extend_from_slice(b"\nendstream"); + o + }; + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /XObject << /Im1 5 0 R >> >> >>".to_vec(), + stream("", b"q 160 0 0 20 20 100 cm /Im1 Do Q"), + stream( + "/Type /XObject /Subtype /Image /Width 16 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 1 /Filter /JBIG2Decode /DecodeParms << /JBIG2Globals 6 0 R >>", + &image, + ), + stream("", &globals), + ]; + let mut out = b"%PDF-1.7\n".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\n", i + 1).as_bytes()); + out.extend_from_slice(o); + out.extend_from_slice(b"\nendobj\n"); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + let mut s = Session::new(); + let id = s.open("scan.pdf", None, Arc::new(out), None).unwrap(); + let d = s.get(id).unwrap(); + let (w, h, rgba) = d.page_image_rgba(0, 0).unwrap(); + assert_eq!((w, h), (16, 2)); + assert!(rgba.chunks(4).all(|p| p == [0, 0, 0, 255]), "the page's default pixel, from the globals"); + // An Office export shares one allowance across its pictures. One past it is left out, and the + // export says the page is missing something, as for a picture past its bytes. + let (pages, truncated) = d.export_pages_within(true, 1 << 20, &mut pdfcraft_cos::ImageBudget::with_work(u64::MAX)); + assert_eq!((pages[0].images.len(), truncated.len()), (1, 0)); + let (pages, truncated) = d.export_pages_within(true, 1 << 20, &mut pdfcraft_cos::ImageBudget::with_work(0)); + assert!(pages[0].images.is_empty()); + assert_eq!(truncated, [0]); +} + +/// An Office export leaves out a picture it can't encode, as it did. That isn't a page cut short: +/// only a picture past what the export may do is reported as one. +#[test] +fn an_office_export_doesnt_call_an_unreadable_picture_truncated() { + let pdf = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /XObject << /Im1 5 0 R >> >> >> endobj\n\ + 4 0 obj << /Length 31 >> stream\nq 160 0 0 20 20 100 cm /Im1 Do Q\nendstream endobj\n\ + 5 0 obj << /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /JPXDecode /Length 4 >> stream\nnone\nendstream endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF" + .to_vec(); + let mut s = Session::new(); + let id = s.open("picture.pdf", None, Arc::new(pdf), None).unwrap(); + let (pages, truncated) = s.get(id).unwrap().export_pages_within(true, 1 << 20, &mut pdfcraft_cos::ImageBudget::with_work(u64::MAX)); + assert!(pages[0].images.is_empty()); + assert_eq!(truncated, Vec::::new()); +} + +/// Copy Image takes a JBIG2 picture as its pixels and as a PNG file, behind another filter +/// (ASCIIHexDecode) too: Export's decoding, through `ImageBudget::decode`. +#[test] +fn copy_image_copies_a_jbig2_picture_behind_another_filter() { + // 16 × 2: the left half of the top row black, the rest white. + let bits = [0x00, 0xFF, 0xFF, 0xFF]; + let coded = pdfcraft_filters::encode_jbig2_generic(&bits, 16, 2).unwrap(); + let hex: Vec = coded.iter().flat_map(|b| format!("{b:02x}").into_bytes()).chain(*b">").collect(); + let stream = |dict: &str, data: &[u8]| { + let mut o = format!("<< {dict} /Length {} >>\nstream\n", data.len()).into_bytes(); + o.extend_from_slice(data); + o.extend_from_slice(b"\nendstream"); + o + }; + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /XObject << /Im1 5 0 R >> >> >>".to_vec(), + stream("", b"q 160 0 0 20 20 100 cm /Im1 Do Q"), + stream( + "/Type /XObject /Subtype /Image /Width 16 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 1 /Filter [/ASCIIHexDecode /JBIG2Decode]", + &hex, + ), + ]; + let mut out = b"%PDF-1.7\n".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\n", i + 1).as_bytes()); + out.extend_from_slice(o); + out.extend_from_slice(b"\nendobj\n"); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + let mut s = Session::new(); + let id = s.open("scan.pdf", None, Arc::new(out), None).unwrap(); + let d = s.get(id).unwrap(); + assert_eq!(d.page_image_at(0, [30.0, 115.0]), Some(0)); + assert_eq!(d.page_image_copy_file(0, 0).unwrap().0, "png"); + let (w, h, rgba) = d.page_image_rgba(0, 0).unwrap(); + assert_eq!((w, h), (16, 2)); + let grey: Vec = rgba.chunks(4).map(|px| px[0]).collect(); + let expected: Vec = (0..32).map(|i| if i < 8 { 0 } else { 255 }).collect(); + assert_eq!(grey, expected, "the picture, black as 0"); +} + +/// `fixture(2)` with the catalog pointing at a page tree object the file does not have (#1656). +fn fixture_without_page_tree() -> Vec { + let bytes = fixture(2); + let catalog = b"<< /Type /Catalog /Pages 2 0 R >>"; + let at = bytes.windows(catalog.len()).position(|w| w == catalog).unwrap(); + let mut out = bytes.clone(); + out[at..at + catalog.len()].copy_from_slice(b"<< /Type /Catalog /Pages 9 0 R >>"); + out } #[test] @@ -8042,15 +8830,20 @@ fn undo_and_redo_roll_back_on_a_refresh_panic_or_error() { // Redo now succeeds cleanly. assert_eq!(s.redo(id).unwrap().as_str(), "Rotate page"); assert_eq!(s.get(id).unwrap().info.pages[0].rotation, 90); + rolls_back_metadata_scope(&mut s, id); + rolls_back_protection_keys(&mut s, id); +} +/// Undo/redo of a metadata edit rolled back after a refresh panic. +fn rolls_back_metadata_scope(s: &mut Session, id: DocId) { // 5. Metadata scope: a panic after `doc.info.title` was written rolls back `info.title` and keeps `edit_notices`. s.apply(id, Edit::SetInfo { key: "Title".into(), value: "Draft".into() }).unwrap(); let notice = TextNotice { field: None, page: 0, ch: '☺', redrawn_elsewhere: false }; - s.doc_mut(id).unwrap().edit_notices.push(notice.clone()); + s.doc_mut(id).unwrap().edit_notices.push(EditNotice::Undrawn(notice.clone())); panic_on_refresh(1); assert_eq!(s.undo(id), Err(EditError::Reopen("injected refresh panic".into()))); assert_eq!(s.get(id).unwrap().info.title.as_deref(), Some("Draft")); - assert_eq!(s.get(id).unwrap().edit_notices, [notice]); + assert_eq!(s.get(id).unwrap().edit_notices, [EditNotice::Undrawn(notice)]); assert_eq!(s.undo(id).unwrap().as_str(), "Change Title"); assert_eq!(s.get(id).unwrap().info.title, None); assert!(s.get(id).unwrap().edit_notices.is_empty()); @@ -8060,7 +8853,10 @@ fn undo_and_redo_roll_back_on_a_refresh_panic_or_error() { assert_eq!(s.get(id).unwrap().info.title, None); assert_eq!(s.redo(id).unwrap().as_str(), "Change Title"); assert_eq!(s.get(id).unwrap().info.title.as_deref(), Some("Draft")); +} +/// Undo/redo of a protection edit rolled back after a refresh panic. +fn rolls_back_protection_keys(s: &mut Session, id: DocId) { // 6. Protection keys: undo/redo rollback restores `editor.keys` and `doc.password` before re-inspecting. let protect = Protection { open_password: Some("u".into()), permissions_password: Some("o".into()), ..Protection::default() }; s.apply(id, Edit::Protect(protect)).unwrap(); @@ -8504,6 +9300,61 @@ fn a_fields_own_script_may_not_save_as_or_show_a_panel() { assert_eq!(o.requests, [js::Request::SaveAs]); } +/// A page of `count` pictures of one flat grey, `side` pixels square, each an object of its own. +fn page_of_pictures(count: usize, side: usize) -> Vec { + let data = pdfcraft_filters::encode_flate(&vec![0x80; side * side]); + let names: String = (0..count).map(|i| format!("/Im{i} {} 0 R ", 5 + i)).collect(); + let content: String = (0..count).map(|i| format!("q 10 0 0 10 0 0 cm /Im{i} Do Q\n")).collect(); + let mut pdf = format!( + "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /XObject << {names}>> >> >> endobj\n\ + 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n", + content.len() + ) + .into_bytes(); + for i in 0..count { + let dict = format!( + "{} 0 obj << /Type /XObject /Subtype /Image /Width {side} /Height {side} /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /FlateDecode /Length {} >> stream\n", + 5 + i, + data.len() + ); + pdf.extend_from_slice(dict.as_bytes()); + pdf.extend_from_slice(&data); + pdf.extend_from_slice(b"\nendstream endobj\n"); + } + pdf.extend_from_slice(b"trailer << /Root 1 0 R >>\n%%EOF"); + pdf +} + +/// The allowance is the export's, not each picture's: a page of many small pictures stops where it +/// runs out, the rest are left out and counted, and the page is listed, which is what the export's +/// warning goes by. A few large pictures are well within it, and all come through. +#[test] +fn a_page_of_many_pictures_stops_at_the_exports_allowance() { + let export = |pdf: Vec, work: &mut pdfcraft_cos::ImageBudget| { + let mut s = Session::new(); + let id = s.open("pictures.pdf", None, Arc::new(pdf), None).unwrap(); + s.get(id).unwrap().export_pages_within(true, 1 << 30, work) + }; + let mut one = pdfcraft_cos::ImageBudget::with_work(u64::MAX); + export(page_of_pictures(1, 2), &mut one); + let cost = u64::MAX - one.work_left(); + assert!(cost > 0); + + let mut work = pdfcraft_cos::ImageBudget::with_work(100 * cost); + let (pages, truncated) = export(page_of_pictures(10_000, 2), &mut work); + assert_eq!(pages[0].images.len(), 100); + assert_eq!(truncated, vec![0]); + assert_eq!(work.refused(), 9_900); + + let mut s = Session::new(); + let id = s.open("large.pdf", None, Arc::new(page_of_pictures(3, 1_000)), None).unwrap(); + let (pages, truncated) = s.get(id).unwrap().export_pages_checked(true, 1 << 30); + assert_eq!(pages[0].images.len(), 3); + assert_eq!(truncated, Vec::::new()); +} + /// A page whose forms go past the reading limit and whose picture goes past the image budget is /// listed once among the truncated pages. #[test] @@ -8550,6 +9401,474 @@ fn a_page_truncated_for_forms_and_pictures_is_listed_once() { assert_eq!(truncated, [0]); } +/// Moving an added image, then rotating it, must keep the moved position. +#[test] +fn added_image_move_then_rotate_keeps_position() { + let mut png = Vec::new(); + { + let mut enc = png::Encoder::new(&mut png, 2, 2); + enc.set_color(png::ColorType::Rgb); + enc.write_header().unwrap().write_image_data(&[255u8; 12]).unwrap(); + } + let (mut s, id) = session_with(1); + s.apply(id, Edit::AddImage { page: 0, rect: Some([20.0, 20.0, 60.0, 60.0]), name: "p.png".into(), bytes: Arc::new(png) }).unwrap(); + let rect = |s: &Session| s.get(id).unwrap().page_images(0)[0].rect; + s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Move([120.0, 120.0, 160.0, 160.0]) }).unwrap(); + let moved = rect(&s); + s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Rotate(1) }).unwrap(); + let rotated = rect(&s); + let c = |r: [f64; 4]| ((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0); + let (a, b) = (c(moved), c(rotated)); + assert!((a.0 - b.0).abs() < 1.0 && (a.1 - b.1).abs() < 1.0, "rotate moved the centre from {a:?} to {b:?}"); +} + +/// Transforming an added image, then rotating it, must keep the moved position. +#[test] +fn added_image_transform_then_rotate_keeps_position() { + let mut png = Vec::new(); + { + let mut enc = png::Encoder::new(&mut png, 2, 2); + enc.set_color(png::ColorType::Rgb); + enc.write_header().unwrap().write_image_data(&[255u8; 12]).unwrap(); + } + let (mut s, id) = session_with(1); + s.apply(id, Edit::AddImage { page: 0, rect: Some([20.0, 20.0, 60.0, 60.0]), name: "p.png".into(), bytes: Arc::new(png) }).unwrap(); + let rect = |s: &Session| s.get(id).unwrap().page_images(0)[0].rect; + // Translate by (100, 100): matrix [1, 0, 0, 1, 100, 100] + s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Transform([1.0, 0.0, 0.0, 1.0, 100.0, 100.0]) }).unwrap(); + let transformed = rect(&s); + s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Rotate(1) }).unwrap(); + let rotated = rect(&s); + let c = |r: [f64; 4]| ((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0); + let (a, b) = (c(transformed), c(rotated)); + assert!((a.0 - b.0).abs() < 1.0 && (a.1 - b.1).abs() < 1.0, "rotate moved the centre from {a:?} to {b:?}"); +} + +/// Moving an added image, then rotating by degrees, must keep the moved position. +#[test] +fn added_image_move_then_rotate_by_keeps_position() { + let mut png = Vec::new(); + { + let mut enc = png::Encoder::new(&mut png, 2, 2); + enc.set_color(png::ColorType::Rgb); + enc.write_header().unwrap().write_image_data(&[255u8; 12]).unwrap(); + } + let (mut s, id) = session_with(1); + s.apply(id, Edit::AddImage { page: 0, rect: Some([20.0, 20.0, 60.0, 60.0]), name: "p.png".into(), bytes: Arc::new(png) }).unwrap(); + let rect = |s: &Session| s.get(id).unwrap().page_images(0)[0].rect; + s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Move([120.0, 120.0, 160.0, 160.0]) }).unwrap(); + let moved = rect(&s); + s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::RotateBy(90.0) }).unwrap(); + let rotated = rect(&s); + let c = |r: [f64; 4]| ((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0); + let (a, b) = (c(moved), c(rotated)); + assert!((a.0 - b.0).abs() < 1.0 && (a.1 - b.1).abs() < 1.0, "rotate_by moved the centre from {a:?} to {b:?}"); +} + +/// Arranging an added image moves it into the document stream and converts it to an ordinary +/// page image (dropping the PCAdded marker), so no empty shell remains in list_added. +#[test] +fn added_image_arrange_converts_to_ordinary_image() { + let mut png = Vec::new(); + { + let mut enc = png::Encoder::new(&mut png, 2, 2); + enc.set_color(png::ColorType::Rgb); + enc.write_header().unwrap().write_image_data(&[255u8; 12]).unwrap(); + } + let (mut s, id) = session_with(1); + s.apply(id, Edit::AddImage { page: 0, rect: Some([20.0, 20.0, 60.0, 60.0]), name: "p1.png".into(), bytes: Arc::new(png.clone()) }).unwrap(); + s.apply(id, Edit::AddImage { page: 0, rect: Some([100.0, 100.0, 200.0, 200.0]), name: "p2.png".into(), bytes: Arc::new(png) }).unwrap(); + assert_eq!(s.get(id).unwrap().page_images(0).len(), 2); + assert_eq!(s.get(id).unwrap().added().len(), 2); + s.apply(id, Edit::EditPageImage { page: 0, index: 1, change: ImageEdit::Arrange(ArrangeOrder::SendToBack) }).unwrap(); + assert_eq!(s.get(id).unwrap().page_images(0).len(), 2); + assert_eq!(s.get(id).unwrap().added().len(), 1, "arranged image is converted and no longer an added-item shell"); +} + +#[test] +fn added_image_crop_then_move_keeps_crop() { + let mut png = Vec::new(); + { + let mut enc = png::Encoder::new(&mut png, 2, 2); + enc.set_color(png::ColorType::Rgb); + enc.write_header().unwrap().write_image_data(&[255u8; 12]).unwrap(); + } + let (mut s, id) = session_with(1); + s.apply(id, Edit::AddImage { page: 0, rect: Some([20.0, 20.0, 100.0, 100.0]), name: "p.png".into(), bytes: Arc::new(png) }).unwrap(); + s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Crop([30.0, 30.0, 70.0, 70.0]) }).unwrap(); + s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Move([50.0, 50.0, 130.0, 130.0]) }).unwrap(); + let img = s.get(id).unwrap().page_images(0)[0].clone(); + assert_eq!(img.visible_rect, Some([60.0, 60.0, 100.0, 100.0])); +} + +#[test] +fn added_image_move_and_transform_on_rotated_and_user_unit_pages() { + let mut png = Vec::new(); + { + let mut enc = png::Encoder::new(&mut png, 2, 2); + enc.set_color(png::ColorType::Rgb); + enc.write_header().unwrap().write_image_data(&[255u8; 12]).unwrap(); + } + let png_bytes = Arc::new(png); + + let cases = [(90, 1.0), (180, 1.0), (270, 1.0), (0, 2.0), (90, 2.0)]; + + for (rotate, user_unit) in cases { + let pdf = fixture_with_rotation_and_user_unit(rotate, user_unit); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("test.pdf", None, Arc::new(pdf), None).expect("opens"); + + s.apply(id, Edit::AddImage { page: 0, rect: Some([10.0, 10.0, 50.0, 50.0]), name: "img.png".into(), bytes: png_bytes.clone() }).unwrap(); + + // Target user-space rectangle R + let r = [30.0, 40.0, 80.0, 90.0]; + s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Move(r) }).unwrap(); + + let painted = s.get(id).unwrap().page_images(0)[0].rect; + for i in 0..4 { + assert!( + (painted[i] - r[i]).abs() < 1e-3, + "Move: rotate={rotate}, user_unit={user_unit}: painted[{i}] = {}, expected {}", + painted[i], + r[i] + ); + } + + let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap(); + let added = pdfcraft_edit::list_added(&cos); + assert_eq!(added.len(), 1); + let stream_obj = match &*cos.get(added[0].obj) { + pdfcraft_cos::Object::Stream(s) => s.clone(), + other => panic!("expected stream, got {other:?}"), + }; + let pc_rect = stream_obj + .dict + .get(b"PCAdded") + .and_then(|o| cos.resolve(o).as_dict().cloned()) + .and_then(|d| d.get(b"Rect").cloned()) + .and_then(|o| cos.resolve(&o).as_array().cloned()) + .unwrap(); + let pc_box: [f64; 4] = + [pc_rect[0].as_f64().unwrap(), pc_rect[1].as_f64().unwrap(), pc_rect[2].as_f64().unwrap(), pc_rect[3].as_f64().unwrap()]; + let content = String::from_utf8(stream_obj.decoded().unwrap()).unwrap(); + let re_idx = content.find(" re W n").expect("re W n in content stream"); + let prefix = &content[..re_idx]; + let nums: Vec = + prefix.split_whitespace().rev().take(4).collect::>().into_iter().rev().map(|val| val.parse::().unwrap()).collect(); + assert_eq!(nums.len(), 4); + let (rx, ry, rw, rh) = (nums[0], nums[1], nums[2], nums[3]); + assert!((rx - pc_box[0]).abs() < 1e-3, "re x {rx} != pc_box[0] {}", pc_box[0]); + assert!((ry - pc_box[1]).abs() < 1e-3, "re y {ry} != pc_box[1] {}", pc_box[1]); + assert!((rw - (pc_box[2] - pc_box[0])).abs() < 1e-3, "re w {rw} != {}", pc_box[2] - pc_box[0]); + assert!((rh - (pc_box[3] - pc_box[1])).abs() < 1e-3, "re h {rh} != {}", pc_box[3] - pc_box[1]); + + // Transform test: user-space translation by (10, 10) + s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Transform([1.0, 0.0, 0.0, 1.0, 10.0, 10.0]) }).unwrap(); + + let expected_transformed = [r[0] + 10.0, r[1] + 10.0, r[2] + 10.0, r[3] + 10.0]; + let painted_transformed = s.get(id).unwrap().page_images(0)[0].rect; + for i in 0..4 { + assert!( + (painted_transformed[i] - expected_transformed[i]).abs() < 1e-3, + "Transform: rotate={rotate}, user_unit={user_unit}: painted[{i}] = {}, expected {}", + painted_transformed[i], + expected_transformed[i] + ); + } + } +} + +#[test] +fn replace_on_added_image_replaces_xobject_and_undo_restores() { + let make_png = |rgb: [u8; 3]| { + let mut png = Vec::new(); + { + let mut enc = png::Encoder::new(&mut png, 2, 2); + enc.set_color(png::ColorType::Rgb); + let data = [rgb[0], rgb[1], rgb[2], rgb[0], rgb[1], rgb[2], rgb[0], rgb[1], rgb[2], rgb[0], rgb[1], rgb[2]]; + enc.write_header().unwrap().write_image_data(&data).unwrap(); + } + Arc::new(png) + }; + let png1 = make_png([255, 0, 0]); + let png2 = make_png([0, 0, 255]); + + let (mut s, id) = session_with(1); + s.apply(id, Edit::AddImage { page: 0, rect: Some([20.0, 20.0, 60.0, 60.0]), name: "img1.png".into(), bytes: png1 }).unwrap(); + + let old_obj = s.get(id).unwrap().page_images(0)[0].object.expect("initial image object ref"); + + s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Replace { name: "img2.png".into(), bytes: png2 } }).unwrap(); + + let new_obj = s.get(id).unwrap().page_images(0)[0].object.expect("new image object ref"); + assert_ne!(new_obj, old_obj, "after Replace the page references the new XObject"); + + s.undo(id).unwrap(); + + let restored_obj = s.get(id).unwrap().page_images(0)[0].object.expect("restored image object ref"); + assert_eq!(restored_obj, old_obj, "undo restores the old XObject"); + + // Also test replacement on an image with active crop (promoted path) + let png3 = make_png([0, 255, 0]); + s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Crop([25.0, 25.0, 55.0, 55.0]) }).unwrap(); + let crop_obj = s.get(id).unwrap().page_images(0)[0].object.expect("crop image object ref"); + assert_eq!(crop_obj, old_obj); + s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Replace { name: "img3.png".into(), bytes: png3 } }).unwrap(); + let promoted_new_obj = s.get(id).unwrap().page_images(0)[0].object.expect("promoted replace object ref"); + assert_ne!(promoted_new_obj, crop_obj); + s.undo(id).unwrap(); + assert_eq!(s.get(id).unwrap().page_images(0)[0].object.expect("undo promoted replace"), crop_obj); +} + +#[test] +fn mirroring_transform_on_added_image_flips_and_non_finite_is_refused() { + let make_png = |rgb: [u8; 3]| { + let mut png = Vec::new(); + { + let mut enc = png::Encoder::new(&mut png, 2, 2); + enc.set_color(png::ColorType::Rgb); + let data = [rgb[0], rgb[1], rgb[2], rgb[0], rgb[1], rgb[2], rgb[0], rgb[1], rgb[2], rgb[0], rgb[1], rgb[2]]; + enc.write_header().unwrap().write_image_data(&data).unwrap(); + } + Arc::new(png) + }; + let png1 = make_png([255, 0, 0]); + + let (mut s, id) = session_with(1); + s.apply(id, Edit::AddImage { page: 0, rect: Some([0.0, 0.0, 80.0, 40.0]), name: "img1.png".into(), bytes: png1 }).unwrap(); + + let initial_img = s.get(id).unwrap().page_images(0)[0].clone(); + assert_eq!(initial_img.rect, [0.0, 0.0, 80.0, 40.0]); + assert!(initial_img.matrix[0] > 0.0, "initially unflipped"); + + // Mirroring transform: [-1.0, 0.0, 0.0, 1.0, 80.0, 0.0] + s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Transform([-1.0, 0.0, 0.0, 1.0, 80.0, 0.0]) }).unwrap(); + + let flipped_img = s.get(id).unwrap().page_images(0)[0].clone(); + assert!(flipped_img.matrix[0] < 0.0, "horizontal mirror flips matrix x-axis, got matrix: {:?}", flipped_img.matrix); + + let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap(); + let added = pdfcraft_edit::list_added(&cos); + assert_eq!(added.len(), 1); + let AddedContent::Image(ai) = &added[0].content else { panic!("expected added image") }; + assert!(ai.flip_h, "PCAdded record must have flip_h = true"); + + // Mirroring horizontally again flips it back + s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Transform([-1.0, 0.0, 0.0, 1.0, 80.0, 0.0]) }).unwrap(); + let unflipped_img = s.get(id).unwrap().page_images(0)[0].clone(); + assert!(unflipped_img.matrix[0] > 0.0, "double horizontal mirror restores positive x-axis"); + let cos2 = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap(); + let AddedContent::Image(ai2) = &pdfcraft_edit::list_added(&cos2)[0].content else { panic!("expected added image") }; + assert!(!ai2.flip_h, "PCAdded record must have flip_h = false"); + + // Mirroring vertically: [1.0, 0.0, 0.0, -1.0, 0.0, 40.0] + s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Transform([1.0, 0.0, 0.0, -1.0, 0.0, 40.0]) }).unwrap(); + let v_flipped_img = s.get(id).unwrap().page_images(0)[0].clone(); + assert!(v_flipped_img.matrix[3] < 0.0, "vertical mirror flips matrix y-axis, got matrix: {:?}", v_flipped_img.matrix); + let cos3 = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap(); + let AddedContent::Image(ai3) = &pdfcraft_edit::list_added(&cos3)[0].content else { panic!("expected added image") }; + assert!(ai3.flip_v, "PCAdded record must have flip_v = true"); + + // Non-finite transform must be refused with error + let res = s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Transform([f64::NAN, 0.0, 0.0, 1.0, 0.0, 0.0]) }); + assert!(res.is_err(), "NaN transform must be refused"); + + let res_inf = s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Transform([1.0, 0.0, 0.0, f64::INFINITY, 0.0, 0.0]) }); + assert!(res_inf.is_err(), "infinite transform must be refused"); + + let res_move = s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Move([f64::NAN, 20.0, 100.0, 60.0]) }); + assert!(res_move.is_err(), "NaN position must be refused"); +} + +#[test] +fn transform_on_added_image_folds_quarter_turns_and_promotes_non_axis_aligned() { + let make_png = |rgb: [u8; 3]| { + let mut png = Vec::new(); + { + let mut enc = png::Encoder::new(&mut png, 2, 2); + enc.set_color(png::ColorType::Rgb); + let data = [rgb[0], rgb[1], rgb[2], rgb[0], rgb[1], rgb[2], rgb[0], rgb[1], rgb[2], rgb[0], rgb[1], rgb[2]]; + enc.write_header().unwrap().write_image_data(&data).unwrap(); + } + Arc::new(png) + }; + let png1 = make_png([0, 128, 255]); + + let (mut s, id) = session_with(1); + s.apply(id, Edit::AddImage { page: 0, rect: Some([100.0, 100.0, 200.0, 150.0]), name: "img.png".into(), bytes: png1 }).unwrap(); + + // 1. A pure 90° clockwise rotation matrix about the image centre: + let rot_90_cw = pdfcraft_edit::turn_about_centre([100.0, 100.0, 200.0, 150.0], 1, false, false); + s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Transform(rot_90_cw) }).unwrap(); + + let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap(); + let added = pdfcraft_edit::list_added(&cos); + assert_eq!(added.len(), 1, "stays as PCAdded image"); + let AddedContent::Image(ai) = &added[0].content else { panic!("expected added image") }; + assert_eq!(ai.rotation, 3, "quarter turn clockwise folds into ai.rotation = 3 (270° CCW)"); + // Centre is (150, 125); rotated width=50, height=100 -> [125, 75, 175, 175] + assert!((ai.rect[0] - 125.0).abs() < 1e-4); + assert!((ai.rect[1] - 75.0).abs() < 1e-4); + assert!((ai.rect[2] - 175.0).abs() < 1e-4); + assert!((ai.rect[3] - 175.0).abs() < 1e-4); + + // 2. A pure 90° counter-clockwise rotation matrix: + let rot_90_ccw = pdfcraft_edit::turn_about_centre(ai.rect, 3, false, false); + s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Transform(rot_90_ccw) }).unwrap(); + + let cos2 = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap(); + let AddedContent::Image(ai2) = &pdfcraft_edit::list_added(&cos2)[0].content else { panic!("expected added image") }; + assert_eq!(ai2.rotation, 0, "quarter turn counter-clockwise restores ai.rotation = 0"); + assert!((ai2.rect[0] - 100.0).abs() < 1e-4); + assert!((ai2.rect[1] - 100.0).abs() < 1e-4); + assert!((ai2.rect[2] - 200.0).abs() < 1e-4); + assert!((ai2.rect[3] - 150.0).abs() < 1e-4); + + // Arbitrary transforms promote the added image, just like RotateBy. + let initial_matrix = s.get(id).unwrap().page_images(0)[0].matrix; + let rot_45 = pdfcraft_edit::turn_by_degrees([100.0, 100.0, 200.0, 150.0], 45.0).unwrap(); + s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Transform(rot_45) }).unwrap(); + let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap(); + assert!(pdfcraft_edit::list_added(&cos).is_empty(), "arbitrary transform promotes the image"); + assert_eq!(s.get(id).unwrap().page_images(0).len(), 1); + let expected = pdfcraft_content::Matrix(initial_matrix).then(&pdfcraft_content::Matrix(rot_45)).0; + let actual = s.get(id).unwrap().page_images(0)[0].matrix; + assert!(actual.into_iter().zip(expected).all(|(a, b)| (a - b).abs() < 1e-3), "{actual:?} != {expected:?}"); + s.undo(id).unwrap(); + let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap(); + assert_eq!(pdfcraft_edit::list_added(&cos).len(), 1, "undo restores added-image editability"); + s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Transform([1.0, 0.5, 0.0, 1.0, 0.0, 0.0]) }).unwrap(); + assert_eq!(s.get(id).unwrap().page_images(0).len(), 1); +} + +#[test] +fn test_added_image_transform_reflections_with_rotation() { + let make_asymmetric_png = || { + let mut px = Vec::with_capacity(20 * 20 * 3); + for y in 0..20 { + for x in 0..20 { + let color = match (x < 10, y < 10) { + (true, true) => [255, 0, 0], // TL: Red + (false, true) => [0, 255, 0], // TR: Green + (true, false) => [0, 0, 255], // BL: Blue + (false, false) => [255, 255, 0], // BR: Yellow + }; + px.extend_from_slice(&color); + } + } + let mut png = Vec::new(); + { + let mut enc = png::Encoder::new(&mut png, 20, 20); + enc.set_color(png::ColorType::Rgb); + enc.write_header().unwrap().write_image_data(&px).unwrap(); + } + Arc::new(png) + }; + + let colour_at = |s: &Session, id: DocId, x: u32, y_from_top: u32| -> [u8; 3] { + let doc = s.get(id).unwrap(); + let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), Default::default()); + let out = r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + let i = ((y_from_top * out.width + x) * 4) as usize; + [out.rgba[i], out.rgba[i + 1], out.rgba[i + 2]] + }; + + let is_red = |c: [u8; 3]| c[0] > 200 && c[1] < 50 && c[2] < 50; + let is_green = |c: [u8; 3]| c[0] < 50 && c[1] > 200 && c[2] < 50; + let is_blue = |c: [u8; 3]| c[0] < 50 && c[1] < 50 && c[2] > 200; + let is_yellow = |c: [u8; 3]| c[0] > 200 && c[1] > 200 && c[2] < 50; + + let check_quadrants = + |s: &Session, id: DocId, tl: fn([u8; 3]) -> bool, tr: fn([u8; 3]) -> bool, bl: fn([u8; 3]) -> bool, br: fn([u8; 3]) -> bool, label: &str| { + let c_tl = colour_at(s, id, 75, 125); + let c_tr = colour_at(s, id, 125, 125); + let c_bl = colour_at(s, id, 75, 175); + let c_br = colour_at(s, id, 125, 175); + assert!(tl(c_tl), "{label} TL mismatch: got {:?}", c_tl); + assert!(tr(c_tr), "{label} TR mismatch: got {:?}", c_tr); + assert!(bl(c_bl), "{label} BL mismatch: got {:?}", c_bl); + assert!(br(c_br), "{label} BR mismatch: got {:?}", c_br); + }; + + // --- CASE 1: 90° clockwise rotation (ImageEdit::Rotate(1), ai.rotation = 3) --- + // Unrotated: TL=Red, TR=Green, BL=Blue, BR=Yellow. + // After 90° CW: TL=Blue, TR=Red, BL=Yellow, BR=Green. + { + // 1a: Flip Horizontal after 90° rotation + let (mut s, id) = session_with(1); + s.apply(id, Edit::AddImage { page: 0, rect: Some([50.0, 100.0, 150.0, 200.0]), name: "img.png".into(), bytes: make_asymmetric_png() }) + .unwrap(); + check_quadrants(&s, id, is_red, is_green, is_blue, is_yellow, "initial"); + + s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Rotate(1) }).unwrap(); + check_quadrants(&s, id, is_blue, is_red, is_yellow, is_green, "90° CW"); + + s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Flip { horizontal: true } }).unwrap(); + let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap(); + let AddedContent::Image(ai) = &pdfcraft_edit::list_added(&cos)[0].content else { panic!() }; + assert_eq!(ai.rotation, 3); + assert!(!ai.flip_h, "at 90° rotation, horizontal flip toggles flip_v, not flip_h"); + assert!(ai.flip_v, "at 90° rotation, horizontal flip toggles flip_v"); + // Flipping horizontally on screen swaps left & right columns: TL=Red, TR=Blue, BL=Green, BR=Yellow + check_quadrants(&s, id, is_red, is_blue, is_green, is_yellow, "90° CW + flip horizontal"); + } + + { + // 1b: Flip Vertical after 90° rotation + let (mut s, id) = session_with(1); + s.apply(id, Edit::AddImage { page: 0, rect: Some([50.0, 100.0, 150.0, 200.0]), name: "img.png".into(), bytes: make_asymmetric_png() }) + .unwrap(); + s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Rotate(1) }).unwrap(); + + s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Flip { horizontal: false } }).unwrap(); + let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap(); + let AddedContent::Image(ai) = &pdfcraft_edit::list_added(&cos)[0].content else { panic!() }; + assert_eq!(ai.rotation, 3); + assert!(ai.flip_h, "at 90° rotation, vertical flip toggles flip_h"); + assert!(!ai.flip_v, "at 90° rotation, vertical flip toggles flip_h, not flip_v"); + // Flipping vertically on screen swaps top & bottom rows: TL=Yellow, TR=Green, BL=Blue, BR=Red + check_quadrants(&s, id, is_yellow, is_green, is_blue, is_red, "90° CW + flip vertical"); + } + + // --- CASE 2: 270° clockwise rotation (ImageEdit::Rotate(3), ai.rotation = 1) --- + // Unrotated: TL=Red, TR=Green, BL=Blue, BR=Yellow. + // After 270° CW: TL=Green, TR=Yellow, BL=Red, BR=Blue. + { + // 2a: Flip Horizontal after 270° rotation + let (mut s, id) = session_with(1); + s.apply(id, Edit::AddImage { page: 0, rect: Some([50.0, 100.0, 150.0, 200.0]), name: "img.png".into(), bytes: make_asymmetric_png() }) + .unwrap(); + s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Rotate(3) }).unwrap(); + check_quadrants(&s, id, is_green, is_yellow, is_red, is_blue, "270° CW"); + + s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Flip { horizontal: true } }).unwrap(); + let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap(); + let AddedContent::Image(ai) = &pdfcraft_edit::list_added(&cos)[0].content else { panic!() }; + assert_eq!(ai.rotation, 1); + assert!(!ai.flip_h, "at 270° rotation, horizontal flip toggles flip_v, not flip_h"); + assert!(ai.flip_v, "at 270° rotation, horizontal flip toggles flip_v"); + // Flipping horizontally on screen swaps left & right columns: TL=Yellow, TR=Green, BL=Blue, BR=Red + check_quadrants(&s, id, is_yellow, is_green, is_blue, is_red, "270° CW + flip horizontal"); + } + + { + // 2b: Flip Vertical after 270° rotation + let (mut s, id) = session_with(1); + s.apply(id, Edit::AddImage { page: 0, rect: Some([50.0, 100.0, 150.0, 200.0]), name: "img.png".into(), bytes: make_asymmetric_png() }) + .unwrap(); + s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Rotate(3) }).unwrap(); + + s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Flip { horizontal: false } }).unwrap(); + let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap(); + let AddedContent::Image(ai) = &pdfcraft_edit::list_added(&cos)[0].content else { panic!() }; + assert_eq!(ai.rotation, 1); + assert!(ai.flip_h, "at 270° rotation, vertical flip toggles flip_h"); + assert!(!ai.flip_v, "at 270° rotation, vertical flip toggles flip_h, not flip_v"); + // Flipping vertically on screen swaps top & bottom rows: TL=Red, TR=Blue, BL=Green, BR=Yellow + check_quadrants(&s, id, is_red, is_blue, is_green, is_yellow, "270° CW + flip vertical"); + } +} + /// ISO 32000-2 Table 192, 12.7.5.2.3 and Table 228 (Comb): a check box, a push button, a comb /// field the file leaves to the viewer. The page as displayed and printed as an image shows the /// check box's caption symbol in its text colour, the button's grey face and the comb's cells in @@ -8632,6 +9951,70 @@ fn legacy_border_dashes_and_the_caret_paragraph_symbol_are_drawn() { assert!(blue.count() > 20, "the pilcrow is drawn beside the caret"); } +/// Import comments with a PDF chosen copies that PDF's comments in one undoable step, and refuses +/// a PDF that has none. +#[test] +fn importing_a_pdf_copies_its_comments() { + let mut s = Session::new(); + let id = s.open("doc.pdf", None, Arc::new(pdf_with_annots(&[])), None).unwrap(); + let review = pdf_with_annots(&[ + b"<< /Type /Annot /Subtype /Text /Rect [10 10 30 30] /Contents (from the review) /P 3 0 R >>", + b"<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] /A << /S /URI /URI (https://example.com/) >> /P 3 0 R >>", + ]); + s.apply(id, Edit::ImportData { name: "review.pdf".into(), bytes: Arc::new(review.clone()) }).unwrap(); + let contents = |s: &Session| s.get(id).unwrap().info.annotations.iter().map(|a| a.contents.clone()).collect::>(); + assert_eq!(contents(&s), [Some("from the review".to_string())], "the comment, not the link"); + assert_eq!(s.undo(id).unwrap().to_string(), "Import review.pdf"); + assert!(s.get(id).unwrap().info.annotations.is_empty()); + + // Bytes before the header, as the reader allows them. + let prefixed = [b"\r\n \n".as_slice(), &review].concat(); + s.apply(id, Edit::ImportData { name: "prefixed.pdf".into(), bytes: Arc::new(prefixed) }).unwrap(); + assert_eq!(contents(&s), [Some("from the review".to_string())]); + s.undo(id).unwrap(); + + let links = pdf_with_annots(&[b"<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] /P 3 0 R >>"]); + let err = s.apply(id, Edit::ImportData { name: "links.pdf".into(), bytes: Arc::new(links.clone()) }).unwrap_err(); + assert_eq!(err.to_string(), pdfcraft_xfdf::DataError::NothingImported.to_string()); + // A PDF that needs a password is refused with that reason, with or without bytes before its header. + let mut locked = pdfcraft_cos::Document::open(Arc::new(review)).unwrap(); + locked + .set_encryption(&pdfcraft_cos::NewEncryption { + algorithm: pdfcraft_cos::Algorithm::Aes256, + user_password: "u", + owner_password: "o", + permissions: 0, + encrypt_metadata: true, + seed: [1; 32], + }) + .unwrap(); + let locked = pdfcraft_cos::write_full(&locked, &Default::default()).unwrap(); + for bytes in [locked.clone(), [b"\n".as_slice(), &locked].concat()] { + let err = s.apply(id, Edit::ImportData { name: "locked.pdf".into(), bytes: Arc::new(bytes) }).unwrap_err(); + assert_eq!(err.to_string(), EditError::Source("locked.pdf: it is password-protected".into()).to_string()); + } + + // An XFDF file that mentions the PDF header is still read as XFDF. + let xfdf = r#"from the data file"#; + s.apply(id, Edit::ImportData { name: "notes.xfdf".into(), bytes: Arc::new(xfdf.as_bytes().to_vec()) }).unwrap(); + assert_eq!(contents(&s), [Some("from the data file".to_string())]); +} + +/// An XFA form imports the comments of a PDF with binary bytes before its header, instead of +/// refusing it as a data file in an unknown encoding. +#[test] +fn an_xfa_form_imports_comments_from_a_pdf_with_binary_bytes_before_its_header() { + let data = "
JP
"; + 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"); + let review = pdf_with_annots(&[b"<< /Type /Annot /Subtype /Text /Rect [10 10 30 30] /Contents (from the review) /P 3 0 R >>"]); + let prefixed = [b"\xC0\xC1\xF5\n".as_slice(), &review].concat(); + s.apply(id, Edit::ImportData { name: "prefixed.pdf".into(), bytes: Arc::new(prefixed) }).unwrap(); + let contents: Vec<_> = s.get(id).unwrap().info.annotations.iter().map(|a| a.contents.clone()).collect(); + assert!(contents.contains(&Some("from the review".to_string())), "{contents:?}"); +} + /// A one-page 200 × 300 document whose page lists `annots` (objects 4, 5, …). fn pdf_with_annots(annots: &[&[u8]]) -> Vec { let list: String = (0..annots.len()).map(|i| format!("{} 0 R ", i + 4)).collect(); @@ -8656,69 +10039,232 @@ fn pdf_with_annots(annots: &[&[u8]]) -> Vec { bytes } -/// ISO 32000-2 Table 224 (`/NeedAppearances`) and Table 192 (`/MK /CA`): a form that leaves its -/// appearances to the viewer shows its check boxes and radio buttons drawn from `/MK`, over the -/// filled squares the file carries: a checked box its check mark (ZapfDingbats `4`), an unchecked -/// one its empty border, a radio button its dot (`l`), on screen and printed as an image. Without -/// `/NeedAppearances` the file's own squares show. Saving writes the file as it was. -#[test] -fn need_appearances_buttons_are_displayed_from_their_captions() { - let pdf = |need: bool| { - let flag = if need { "/NeedAppearances true " } else { "" }; - format!( - "%PDF-1.7\n\ - 1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >> endobj\n\ - 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ - 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 400 200] /Annots [6 0 R 7 0 R 8 0 R] >> endobj\n\ - 4 0 obj << /Fields [6 0 R 7 0 R 8 0 R] {flag}/DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> endobj\n\ - 5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> endobj\n\ - 6 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (on) /V /Yes /AS /Yes /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (4) >> /Rect [20 100 60 140] /F 4 /P 3 0 R /AP << /N << /Yes 9 0 R /Off 9 0 R >> >> >> endobj\n\ - 7 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (off) /V /Off /AS /Off /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (4) >> /Rect [100 100 140 140] /F 4 /P 3 0 R /AP << /N << /Yes 9 0 R /Off 9 0 R >> >> >> endobj\n\ - 8 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /Ff 32768 /T (radio) /V /B /AS /B /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (l) >> /Rect [180 100 220 140] /F 4 /P 3 0 R /AP << /N << /B 9 0 R /Off 9 0 R >> >> >> endobj\n\ - 9 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 40 40] /Length 18 >> stream\n0 g 4 4 32 32 re f\nendstream endobj\n\ - trailer << /Root 1 0 R >>\n%%EOF" - ) - .into_bytes() - }; - let render = |bytes: Arc>| { - let mut r = pdfcraft_render::PageRenderer::new(bytes, pdfcraft_render::RenderConfig::default()); - r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }) - }; - // Dark pixels of the page (drawn at scale 1, 400 x 200) in the user-space box x0..x1, y0..y1. - let ink = |page: &pdfcraft_render::RenderedPage, (x0, y0, x1, y1): (u32, u32, u32, u32)| { - let (px, _) = page.rgba.as_chunks::<4>(); - (200 - y1..200 - y0) - .flat_map(|y| (x0..x1).map(move |x| (x, y))) - .filter(|&(x, y)| px.get((y * page.width + x) as usize).is_some_and(|p| p[0] < 128 && p[1] < 128 && p[2] < 128)) - .count() - }; - // In a widget whose lower-left corner is (x, y): its whole inside, the top-left of its inside - // (where a check mark leaves the box empty), and the lower-left corner (outside a dot). - let inside = |x: u32, y: u32| (x + 6, y + 6, x + 34, y + 34); - let top_left = |x: u32, y: u32| (x + 6, y + 26, x + 14, y + 34); - let corner = |x: u32, y: u32| (x + 5, y + 5, x + 9, y + 9); - let mut s = Session::new(); - let id = s.open("form.pdf", None, Arc::new(pdf(true)), None).unwrap(); - let shown = render(s.get(id).unwrap().display.clone()); - let raster = pdfcraft_print::Raster { dpi: 72.0, grayscale: false }; - let pictures = crate::print_raster::rasterize(s.get(id).unwrap(), &[0], &raster, pdfcraft_print::Content::Document).unwrap(); - let printed = render(Arc::new(pictures)); - for (page, how) in [(&shown, "shown"), (&printed, "printed")] { - let (check, check_corner) = (ink(page, inside(20, 100)), ink(page, top_left(20, 100))); - assert!(check > 100 && check < 600, "{how}: a check mark, not the 28 x 28 square: {check}"); - assert_eq!(check_corner, 0, "{how}: the check mark leaves the top left empty"); - assert_eq!(ink(page, inside(100, 100)), 0, "{how}: the unchecked box is empty"); - assert!(ink(page, (100, 100, 140, 102)) > 30, "{how}: and keeps its border"); - let dot = ink(page, (188, 108, 212, 132)); - assert!(dot > 300 && ink(page, corner(180, 100)) == 0, "{how}: the radio button's dot: {dot}"); - } - let saved = s.save_bytes(id).unwrap(); - assert!(saved.windows(18).any(|w| w == b"0 g 4 4 32 32 re f"), "the file keeps its own appearances"); - - // Without /NeedAppearances the file's filled squares are shown. - let id = s.open("plain.pdf", None, Arc::new(pdf(false)), None).unwrap(); - let shown = render(s.get(id).unwrap().display.clone()); - for x in [20, 100, 180] { - assert!(ink(&shown, inside(x, 100)) > 700, "the file's square at {x}"); - } -} +/// ISO 32000-2 Table 224 (`/NeedAppearances`) and Table 192 (`/MK /CA`): a form that leaves its +/// appearances to the viewer shows its check boxes and radio buttons drawn from `/MK`, over the +/// filled squares the file carries: a checked box its check mark (ZapfDingbats `4`), an unchecked +/// one its empty border, a radio button its dot (`l`), on screen and printed as an image. Without +/// `/NeedAppearances` the file's own squares show. Saving writes the file as it was. +#[test] +fn need_appearances_buttons_are_displayed_from_their_captions() { + let pdf = |need: bool| { + let flag = if need { "/NeedAppearances true " } else { "" }; + format!( + "%PDF-1.7\n\ + 1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 400 200] /Annots [6 0 R 7 0 R 8 0 R] >> endobj\n\ + 4 0 obj << /Fields [6 0 R 7 0 R 8 0 R] {flag}/DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> endobj\n\ + 5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> endobj\n\ + 6 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (on) /V /Yes /AS /Yes /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (4) >> /Rect [20 100 60 140] /F 4 /P 3 0 R /AP << /N << /Yes 9 0 R /Off 9 0 R >> >> >> endobj\n\ + 7 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (off) /V /Off /AS /Off /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (4) >> /Rect [100 100 140 140] /F 4 /P 3 0 R /AP << /N << /Yes 9 0 R /Off 9 0 R >> >> >> endobj\n\ + 8 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /Ff 32768 /T (radio) /V /B /AS /B /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (l) >> /Rect [180 100 220 140] /F 4 /P 3 0 R /AP << /N << /B 9 0 R /Off 9 0 R >> >> >> endobj\n\ + 9 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 40 40] /Length 18 >> stream\n0 g 4 4 32 32 re f\nendstream endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF" + ) + .into_bytes() + }; + let render = |bytes: Arc>| { + let mut r = pdfcraft_render::PageRenderer::new(bytes, pdfcraft_render::RenderConfig::default()); + r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }) + }; + // Dark pixels of the page (drawn at scale 1, 400 x 200) in the user-space box x0..x1, y0..y1. + let ink = |page: &pdfcraft_render::RenderedPage, (x0, y0, x1, y1): (u32, u32, u32, u32)| { + let (px, _) = page.rgba.as_chunks::<4>(); + (200 - y1..200 - y0) + .flat_map(|y| (x0..x1).map(move |x| (x, y))) + .filter(|&(x, y)| px.get((y * page.width + x) as usize).is_some_and(|p| p[0] < 128 && p[1] < 128 && p[2] < 128)) + .count() + }; + // In a widget whose lower-left corner is (x, y): its whole inside, the top-left of its inside + // (where a check mark leaves the box empty), and the lower-left corner (outside a dot). + let inside = |x: u32, y: u32| (x + 6, y + 6, x + 34, y + 34); + let top_left = |x: u32, y: u32| (x + 6, y + 26, x + 14, y + 34); + let corner = |x: u32, y: u32| (x + 5, y + 5, x + 9, y + 9); + let mut s = Session::new(); + let id = s.open("form.pdf", None, Arc::new(pdf(true)), None).unwrap(); + let shown = render(s.get(id).unwrap().display.clone()); + let raster = pdfcraft_print::Raster { dpi: 72.0, grayscale: false }; + let pictures = crate::print_raster::rasterize(s.get(id).unwrap(), &[0], &raster, pdfcraft_print::Content::Document).unwrap(); + let printed = render(Arc::new(pictures)); + for (page, how) in [(&shown, "shown"), (&printed, "printed")] { + let (check, check_corner) = (ink(page, inside(20, 100)), ink(page, top_left(20, 100))); + assert!(check > 100 && check < 600, "{how}: a check mark, not the 28 x 28 square: {check}"); + assert_eq!(check_corner, 0, "{how}: the check mark leaves the top left empty"); + assert_eq!(ink(page, inside(100, 100)), 0, "{how}: the unchecked box is empty"); + assert!(ink(page, (100, 100, 140, 102)) > 30, "{how}: and keeps its border"); + let dot = ink(page, (188, 108, 212, 132)); + assert!(dot > 300 && ink(page, corner(180, 100)) == 0, "{how}: the radio button's dot: {dot}"); + } + let saved = s.save_bytes(id).unwrap(); + assert!(saved.windows(18).any(|w| w == b"0 g 4 4 32 32 re f"), "the file keeps its own appearances"); + + // Without /NeedAppearances the file's filled squares are shown. + let id = s.open("plain.pdf", None, Arc::new(pdf(false)), None).unwrap(); + let shown = render(s.get(id).unwrap().display.clone()); + for x in [20, 100, 180] { + assert!(ink(&shown, inside(x, 100)) > 700, "the file's square at {x}"); + } +} + +#[test] +fn repeated_backdrop_artwork_survives_background_undo_redo_and_both_save_modes() { + let white = "1 g"; + let rectangle = "0 0 100 100 re f"; + let artwork = "q 0 g 0.25 0 0 0.25 20 20 cm"; + let raw = format!( + "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents [4 0 R 5 0 R 6 0 R 5 0 R 7 0 R] >> endobj\n\ + 4 0 obj << /Length {} >> stream\n{white}\nendstream endobj\n\ + 5 0 obj << /Length {} >> stream\n{rectangle}\nendstream endobj\n\ + 6 0 obj << /Length {} >> stream\n{artwork}\nendstream endobj\n\ + 7 0 obj << /Length 1 >> stream\nQ\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF", + white.len(), + rectangle.len(), + artwork.len() + ); + // Start with a conventional xref, so the first save can exercise incremental output. + let source = pdfcraft_cos::Document::open(Arc::new(raw.into_bytes())).unwrap(); + let bytes = pdfcraft_cos::write_full(&source, &pdfcraft_cos::SaveOptions::default()).unwrap(); + let mut s = Session::new(); + let id = s.open("shared-backdrop.pdf", None, Arc::new(bytes), None).unwrap(); + let black = |s: &Session, id| { + let doc = s.get(id).unwrap(); + let mut renderer = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), doc.config.clone()); + let out = renderer.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + assert!(out.error.is_none(), "{:?}", out.error); + assert_eq!((out.width, out.height), (100, 100)); + // PDF point (30, 30), inside the later black square, in top-down raster coordinates. + let i = ((70 * out.width + 30) * 4) as usize; + let rgba = &out.rgba[i..i + 4]; + assert!(rgba[0] < 20 && rgba[1] < 20 && rgba[2] < 20, "later artwork survives: {rgba:?}"); + }; + black(&s, id); + s.apply( + id, + Edit::AddBackground { pages: vec![0], settings: Background { color: [1.0, 0.0, 0.0], ..Background::default() }, replace: false, file: None }, + ) + .unwrap(); + black(&s, id); + s.undo(id).unwrap(); + black(&s, id); + s.redo(id).unwrap(); + black(&s, id); + let saved = s.save_bytes(id).unwrap(); + let reopened = s.open("incremental.pdf", None, saved.clone(), None).unwrap(); + black(&s, reopened); + let doc = pdfcraft_cos::Document::open(saved).unwrap(); + let full = pdfcraft_cos::write_full(&doc, &pdfcraft_cos::SaveOptions::default()).unwrap(); + let rewritten = s.open("full.pdf", None, Arc::new(full), None).unwrap(); + black(&s, rewritten); + s.apply(rewritten, Edit::RemoveMarks { kind: MarkKind::Background }).unwrap(); + black(&s, rewritten); +} + +fn contains(haystack: &[u8], needle: &[u8]) -> bool { + haystack.windows(needle.len()).any(|w| w == needle) +} + +#[test] +fn saving_after_deleting_pages_rewrites_the_file_without_them() { + let (mut s, id) = session_with(6); + let original = s.save_bytes(id).unwrap(); + s.apply(id, Edit::DeletePages { pages: vec![1, 2, 3, 4] }).unwrap(); + let saved = s.save_bytes(id).unwrap(); + assert!(saved.len() < original.len(), "the file shrank: {} -> {}", original.len(), saved.len()); + assert!(!saved.starts_with(original.as_slice()), "a full rewrite, not an update appended to the old file"); + assert!(!contains(&saved, b"Page 3"), "a deleted page's text is not left in an earlier revision"); + let mut again = Session::new(); + let id2 = again.open("saved.pdf", None, saved, None).unwrap(); + assert_eq!(page_texts(&again, id2), ["Page 1", "Page 6"]); +} + +#[test] +fn saving_after_deleting_pages_from_a_signed_file_stays_incremental() { + let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap(); + let digital_id = sign::pkcs12::open(&p12, "test").unwrap(); + let mut s = Session::new().with_clock(|| 1_800_000_000); + let id = s.open("fixture.pdf", None, Arc::new(fixture(3)), None).unwrap(); + let opts = SignOptions { page: 0, rect: Some([20.0, 20.0, 160.0, 50.0]), ..SignOptions::default() }; + let signed = s.sign(id, &digital_id, opts).unwrap(); + s.mark_signed(id, signed.clone(), None).unwrap(); + s.apply(id, Edit::DeletePages { pages: vec![2] }).unwrap(); + let saved = s.save_bytes(id).unwrap(); + assert!(saved.starts_with(signed.as_ref()), "the signed bytes stay an incremental update"); + assert!(s.get(id).unwrap().is_signed()); +} + +#[test] +fn saving_after_a_batch_that_deletes_pages_rewrites_the_file() { + let (mut s, id) = session_with(6); + let original = s.save_bytes(id).unwrap(); + let batch = Edit::Batch { + label: "Delete and rotate".into(), + edits: vec![ + Edit::Batch { label: "Delete pages".into(), edits: vec![Edit::DeletePages { pages: vec![1, 2, 3, 4] }] }, + Edit::RotatePages { pages: vec![0], degrees: 90 }, + ], + }; + s.apply(id, batch).unwrap(); + let saved = s.save_bytes(id).unwrap(); + assert!(!saved.starts_with(original.as_slice()), "a full rewrite, not an update appended to the old file"); + assert!(!contains(&saved, b"Page 3"), "a deleted page's text is not left in an earlier revision"); +} + +#[test] +fn saving_without_deleting_pages_stays_incremental() { + let (mut s, id) = session_with(3); + let original = s.save_bytes(id).unwrap(); + s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap(); + let saved = s.save_bytes(id).unwrap(); + assert!(saved.starts_with(original.as_slice()), "an edit that deletes nothing is still appended"); +} + +#[test] +fn original_image_edit_does_not_match_an_added_sibling_of_the_same_xobject() { + let mut document = pdfcraft_cos::Document::open(Arc::new(jpeg_cover_pdf(650))).unwrap(); + let original = pdfcraft_edit::page_images(&document, 0).unwrap()[0].clone(); + let added = AddedImage::new([20.0, 30.0, 80.0, 90.0], original.object.unwrap()); + pdfcraft_edit::add_content(&mut document, 0, &AddedContent::Image(added.clone())).unwrap(); + let bytes = pdfcraft_cos::write_full(&document, &pdfcraft_cos::SaveOptions::default()).unwrap(); + let mut session = Session::new(); + let id = session.open("shared-image.pdf", None, Arc::new(bytes), None).unwrap(); + let before_added = session.get(id).unwrap().added()[0].content.clone(); + session.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Move([40.0, 50.0, 140.0, 150.0]) }).unwrap(); + let doc = session.get(id).unwrap(); + assert_eq!(doc.added()[0].content, before_added, "matching an original Do must not select the added sibling's metadata"); + assert_eq!(doc.page_images(0)[0].rect, [40.0, 50.0, 140.0, 150.0]); +} + +#[test] +fn image_transform_engine_refuses_singular_matrices_without_mutation() { + let mut png = Vec::new(); + let mut enc = png::Encoder::new(&mut png, 1, 1); + enc.set_color(png::ColorType::Rgb); + enc.write_header().unwrap().write_image_data(&[100, 150, 200]).unwrap(); + let mut s = Session::new(); + let bytes = s.create_from_images(&[("pixel.png".into(), png)]).unwrap(); + let id = s.open("image.pdf", None, bytes, None).unwrap(); + for matrix in [[0.0, 0.0, 0.0, 1.0, 0.0, 0.0], [1.0, 1.0, 1.0, 1.0 + 1e-12, 0.0, 0.0], [1e308, 0.0, 0.0, 1e308, 0.0, 0.0]] { + let before = s.get(id).unwrap().bytes.clone(); + assert!(s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Transform(matrix) }).is_err()); + assert_eq!(s.get(id).unwrap().bytes, before); + } +} + +#[test] +fn fill_sign_multiline_text_survives_engine_save_reopen_and_render() { + let (mut session, id) = rotated_blank(0); + let text = "\nAda\n\nLovelace\n"; + let shape = Shape::Typewriter { rect: text_box_at(0, [20.0, 250.0], 120.0, 66.0).unwrap(), font_size: 10.0 }; + let style = Style::default_for(&shape); + session.apply(id, Edit::AddAnnotation(NewAnnotation { page: 0, shape, style, contents: text.into(), author: "Test".into() })).unwrap(); + assert_eq!(session.get(id).unwrap().info.annotations[0].contents.as_deref(), Some(text), "engine keeps outer typewriter line breaks"); + let before = render_page(&session, id); + assert!(before.rgba.as_chunks::<4>().0.iter().any(|p| p[..3] == [0, 0, 0]), "the appearance contains ink"); + let bytes = session.save_bytes(id).unwrap(); + let mut reopened = Session::new(); + let again = reopened.open("multiline.pdf", None, bytes, None).unwrap(); + assert_eq!(reopened.get(again).unwrap().info.annotations[0].contents.as_deref(), Some(text), "reopening preserves all blank lines"); + assert_eq!(before.rgba, render_page(&reopened, again).rgba, "the saved appearance is identical"); +} diff --git a/crates/engine/src/text_fonts.rs b/crates/engine/src/text_fonts.rs index 859bc1a6f8..e2b413d88d 100644 --- a/crates/engine/src/text_fonts.rs +++ b/crates/engine/src/text_fonts.rs @@ -32,17 +32,29 @@ pub fn installed_directories() -> Vec { } } -/// Bounded, symlink-free scan. Only standalone, openly licensed, embeddable faces are listed. +/// Bounded scan with resolved roots and no descendant symlinks. +/// Only standalone, openly licensed, embeddable faces are listed. /// The caller controls roots (automation resolves confinement before calling this). pub fn discover(roots: &[PathBuf]) -> Vec { + discover_budgeted(roots, 64u64 << 20) +} + +/// Bounded scan with resolved roots and no descendant symlinks. `budget` caps the total +/// size of the fonts listed: a rejected file (unreadable, not openly licensed, a duplicate) +/// does not spend it. Every file read +/// counts against four times the budget, so a folder of unusable fonts cannot stall the scan. +pub fn discover_budgeted(roots: &[PathBuf], mut budget: u64) -> Vec { use std::io::Read; - let mut pending: Vec<_> = roots.iter().take(16).map(|r| (r.clone(), 0usize)).collect(); + // User font directories themselves are often links. Resolve only the explicitly + // supplied roots; links found below them must not escape an automation read root. + let mut pending: Vec<_> = roots.iter().take(16).filter_map(|r| r.canonicalize().ok()).map(|r| (r, 0usize)).collect(); let mut files = Vec::new(); let mut entries = 0usize; - let mut budget = 64u64 << 20; + let mut read_allowance = budget.saturating_mul(4); let mut seen = std::collections::HashSet::new(); + let mut seen_face_content = std::collections::HashSet::new(); while let Some((path, depth)) = pending.pop() { - if entries >= 4096 || budget == 0 { + if entries >= 4096 || budget == 0 || read_allowance == 0 { break; } entries += 1; @@ -53,26 +65,194 @@ pub fn discover(roots: &[PathBuf]) -> Vec { if meta.is_dir() && depth < 4 { if let Ok(children) = std::fs::read_dir(&path) { let remaining = 4096usize.saturating_sub(entries.saturating_add(pending.len())); - pending.extend(children.take(remaining).filter_map(Result::ok).map(|p| (p.path(), depth + 1))); + // Best effort: unreadable entries are skipped and do not count against the entry allowance. + let mut sorted_children: Vec<_> = children.filter_map(Result::ok).take(remaining).map(|p| p.path()).collect(); + sorted_children.sort(); + pending.extend(sorted_children.into_iter().rev().map(|p| (p, depth + 1))); } } else if meta.is_file() && meta.len() <= (16 << 20) && font_extension(&path) { - if meta.len() > budget { + if meta.len() > budget || meta.len() > read_allowance { continue; } - budget = budget.saturating_sub(meta.len()); + read_allowance = read_allowance.saturating_sub(meta.len()); let mut bytes = Vec::new(); - let read = std::fs::File::open(&path).and_then(|f| f.take((16 << 20) + 1).read_to_end(&mut bytes)); - if read.is_err() || pdfcraft_fonts::embedding::validate_document_input(&bytes).is_err() { + let read = std::fs::File::open(&path).and_then(|f| f.take(meta.len().saturating_add(1)).read_to_end(&mut bytes)); + if read.is_err() || bytes.len() != meta.len() as usize { + continue; + } + if pdfcraft_fonts::embedding::validate_document_input(&bytes).is_err() { continue; } if let Ok((family, face)) = pdfcraft_fonts::embedding::names(&bytes) { - files.push(FontFile { path, family, face }); + let key = font_content_key(&bytes); + if seen_face_content.insert((face.clone(), key)) { + budget = budget.saturating_sub(meta.len()); + files.push(FontFile { path, family, face }); + } } } } files.sort_by(|a, b| a.face.cmp(&b.face).then(a.path.cmp(&b.path))); files } + +fn font_content_key(bytes: &[u8]) -> (usize, u64) { + use std::hash::{DefaultHasher, Hasher}; + let mut h = DefaultHasher::new(); + h.write(bytes); + (bytes.len(), h.finish()) +} fn font_extension(path: &Path) -> bool { path.extension().and_then(std::ffi::OsStr::to_str).is_some_and(|e| e.eq_ignore_ascii_case("ttf") || e.eq_ignore_ascii_case("otf")) } + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(unix)] + #[test] + fn a_linked_font_root_is_scanned_but_descendant_links_are_not() { + let root = std::env::temp_dir().join(format!("pdfcraft-font-links-{}", std::process::id())); + let real = root.join("real"); + std::fs::create_dir_all(&real).unwrap(); + let source = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts/Inter-Regular.ttf"); + std::fs::copy(&source, real.join("regular.ttf")).unwrap(); + let link = root.join("linked"); + std::os::unix::fs::symlink(&real, &link).unwrap(); + std::os::unix::fs::symlink(&real, real.join("cycle")).unwrap(); + std::os::unix::fs::symlink(source.with_file_name("Inter-Medium.ttf"), real.join("escape.ttf")).unwrap(); + let found = discover(&[link]); + let expected = real.canonicalize().unwrap().join("regular.ttf"); + std::fs::remove_dir_all(&root).unwrap(); + assert_eq!(found.len(), 1); + assert_eq!(found.first().unwrap().face, "Inter-Regular"); + assert_eq!(found.first().unwrap().path, expected); + } + + #[test] + fn discovery_keeps_all_valid_faces_within_its_scan_budget() { + let root = std::env::temp_dir().join(format!("pdfcraft-font-count-{}", std::process::id())); + std::fs::create_dir_all(&root).unwrap(); + let source = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts/Inter-Regular.ttf"); + let bytes = std::fs::read(source).unwrap(); + for id in 0..501u32 { + let mut font = bytes.clone(); + // Valid standalone fonts with distinct bytes: an unreferenced trailer + // does not change their tables or embedding licence. + font.extend_from_slice(&id.to_le_bytes()); + std::fs::write(root.join(format!("font-{id:03}.ttf")), font).unwrap(); + } + let found = discover_budgeted(std::slice::from_ref(&root), (bytes.len() as u64 + 4) * 501); + std::fs::remove_dir_all(&root).unwrap(); + assert_eq!(found.len(), 501, "do not silently discard accepted fonts after sorting"); + } + + #[test] + fn test_installed_directories() { + let dirs = installed_directories(); + assert!(!dirs.is_empty()); + } + + #[test] + fn test_font_extension() { + assert!(font_extension(Path::new("Arial.ttf"))); + assert!(font_extension(Path::new("Noto.otf"))); + assert!(font_extension(Path::new("CUSTOM.TTF"))); + assert!(font_extension(Path::new("ROBOTO.OTF"))); + assert!(!font_extension(Path::new("document.pdf"))); + assert!(!font_extension(Path::new("font.woff"))); + assert!(!font_extension(Path::new("font.woff2"))); + assert!(!font_extension(Path::new("no_extension"))); + } + + #[test] + fn test_font_content_key() { + let empty = font_content_key(&[]); + assert_eq!(empty.0, 0); + + let a = font_content_key(b"TrueType font sample data bytes"); + let b = font_content_key(b"TrueType font sample data bytes"); + let c = font_content_key(b"Different font sample data bytes"); + + assert_eq!(a, b, "identical bytes must produce identical keys"); + assert_ne!(a, c, "differing bytes must produce distinct keys"); + assert_eq!(a.0, b"TrueType font sample data bytes".len()); + } + + #[test] + fn test_discover_empty_and_nonexistent_roots() { + let empty = discover(&[]); + assert!(empty.is_empty()); + + let nonexistent = discover(&[PathBuf::from("Z:\\nonexistent_font_path_xyz_12345")]); + assert!(nonexistent.is_empty()); + } + + #[test] + fn test_discover_deduplicates_non_adjacent_same_content() { + let root = std::env::temp_dir().join(format!("pdfcraft-font-dedup-test-{}", std::process::id())); + let dir_a = root.join("a"); + let dir_b = root.join("b"); + let dir_c = root.join("c"); + let _ = std::fs::create_dir_all(&dir_a); + let _ = std::fs::create_dir_all(&dir_b); + let _ = std::fs::create_dir_all(&dir_c); + + let inter_reg = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts/Inter-Regular.ttf"); + let inter_med = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts/Inter-Medium.ttf"); + + if inter_reg.exists() && inter_med.exists() { + let _ = std::fs::copy(&inter_reg, dir_a.join("FontA.ttf")); + let _ = std::fs::copy(&inter_med, dir_b.join("FontB.ttf")); + let _ = std::fs::copy(&inter_reg, dir_c.join("FontC.ttf")); + + let discovered = discover(std::slice::from_ref(&root)); + let _ = std::fs::remove_dir_all(&root); + + assert_eq!(discovered.len(), 2, "identical content in non-adjacent paths is deduplicated"); + let faces: Vec<&str> = discovered.iter().map(|f| f.face.as_str()).collect(); + assert!(faces.contains(&"Inter-Regular")); + assert!(faces.contains(&"Inter-Medium")); + } else { + let _ = std::fs::remove_dir_all(&root); + } + } + + #[test] + fn rejected_fonts_do_not_spend_the_budget_and_the_order_is_deterministic() { + let root = std::env::temp_dir().join(format!("pdfcraft-font-budget-test-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).unwrap(); + + let fonts = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts"); + let reg_len = std::fs::metadata(fonts.join("Inter-Regular.ttf")).unwrap().len(); + let med_len = std::fs::metadata(fonts.join("Inter-Medium.ttf")).unwrap().len(); + + // An unusable candidate, scanned first (sorted before the real fonts). + std::fs::write(root.join("00_candidate_invalid.ttf"), vec![0u8; 400_000]).unwrap(); + std::fs::copy(fonts.join("Inter-Regular.ttf"), root.join("01_Inter-Regular.ttf")).unwrap(); + + // The budget fits Inter-Regular alone; the rejected file must not use it up first. + let run1 = discover_budgeted(std::slice::from_ref(&root), reg_len + 10); + let run2 = discover_budgeted(std::slice::from_ref(&root), reg_len + 10); + let faces = |run: &[FontFile]| run.iter().map(|f| (f.face.clone(), f.path.clone())).collect::>(); + assert_eq!(run1.iter().map(|f| f.face.as_str()).collect::>(), ["Inter-Regular"], "a rejected file spends no budget"); + assert_eq!(faces(&run1), faces(&run2)); + + // Accepted fonts do spend it: Inter-Medium no longer fits after Inter-Regular. + std::fs::copy(fonts.join("Inter-Medium.ttf"), root.join("02_Inter-Medium.ttf")).unwrap(); + let one = discover_budgeted(std::slice::from_ref(&root), reg_len + 10); + assert_eq!(one.iter().map(|f| f.face.as_str()).collect::>(), ["Inter-Regular"]); + let both = discover_budgeted(std::slice::from_ref(&root), reg_len + med_len); + assert_eq!(both.len(), 2); + + // Reading stops at four times the budget, whatever was rejected. + for i in 0..4 { + std::fs::write(root.join(format!("00_unusable_{i}.ttf")), vec![0u8; reg_len as usize]).unwrap(); + } + let capped = discover_budgeted(std::slice::from_ref(&root), reg_len + 10); + let _ = std::fs::remove_dir_all(&root); + assert!(capped.is_empty(), "the read allowance ends the scan before the real fonts"); + } +} diff --git a/crates/engine/src/text_preview.rs b/crates/engine/src/text_preview.rs index 290231cf73..f2e60dac07 100644 --- a/crates/engine/src/text_preview.rs +++ b/crates/engine/src/text_preview.rs @@ -1,4 +1,5 @@ -//! The page beneath an inline text editor, with the source glyphs removed on a COS clone. +//! The page beneath an inline text editor, with the source glyphs removed on a COS clone; and, +//! the same way, the page beneath a dragged added item or the marks an Update replaces. use std::sync::Arc; @@ -18,18 +19,75 @@ impl TextEditPreview { } pub fn render_background(&self, scale: f32) -> Result { + render_one(&self.bytes, &self.config, self.page, scale) + } +} + +/// A document's pages without its marks of one kind ([`Document::without_marks`]). +pub struct UnmarkedPages { + bytes: Arc>, + config: RenderConfig, + /// Parsed on the first [`Self::render_page`], once (a copy that can't be parsed isn't tried + /// again), and kept: parsing costs about as much as reading the document. + parsed: Option, + /// How many times it was parsed. + #[cfg(test)] + builds: u64, +} + +impl UnmarkedPages { + pub fn renderer(&self) -> RenderPool { + RenderPool::new(self.bytes.clone(), 1, self.config.clone()) + } + + /// `page` at `scale`, on this thread. + pub fn render_page(&mut self, page: usize, scale: f32) -> Result { if !scale.is_finite() || scale <= 0.0 { return Err("preview scale must be finite and positive".into()); } - guard(|| { - let mut renderer = PageRenderer::new(self.bytes.clone(), self.config.clone()); - let out = renderer.render(RenderRequest { page: self.page, scale, ..Default::default() }); - if let Some(e) = &out.error { - return Err(e.clone()); + if self.parsed.is_none() { + self.parsed = Some(PageRenderer::new(self.bytes.clone(), self.config.clone())); + #[cfg(test)] + { + self.builds += 1; } - Ok(out) - })? + } + let Some(renderer) = self.parsed.as_mut() else { return Err("the document could not be parsed".into()) }; + let out = guard(|| renderer.render(RenderRequest { page, scale, ..Default::default() })); + let out = match out { + Ok(out) => out, + Err(e) => { + // A panic may have left it half-way: parse afresh next time. + self.parsed = None; + return Err(e); + } + }; + if let Some(e) = &out.error { + return Err(e.clone()); + } + Ok(out) } + + /// How many times [`Self::render_page`] has parsed the document. + #[cfg(test)] + pub(crate) fn parser_builds(&self) -> u64 { + self.builds + } +} + +/// `page` of `bytes` at `scale`, on this thread. +fn render_one(bytes: &Arc>, config: &RenderConfig, page: usize, scale: f32) -> Result { + if !scale.is_finite() || scale <= 0.0 { + return Err("preview scale must be finite and positive".into()); + } + guard(|| { + let mut renderer = PageRenderer::new(bytes.clone(), config.clone()); + let out = renderer.render(RenderRequest { page, scale, ..Default::default() }); + if let Some(e) = &out.error { + return Err(e.clone()); + } + Ok(out) + })? } impl Document { @@ -90,6 +148,33 @@ impl Document { })? } + /// The document without its marks of `kind`, on every page: what Update replaces them on, so + /// its preview doesn't draw the new marks over the old ones (or tint the old background). + /// Made once for all the pages a dialog steps through, as cloning and writing the document + /// costs about a save. `None` when the document has none, and the thumbnails already are + /// those pages. Read-only, like [`Self::text_edit_preview`]. + pub fn without_marks(&self, kind: crate::MarkKind) -> Result, String> { + if !self.marks().contains(&kind) { + return Ok(None); + } + guard(|| { + let editor = self.editor.as_ref().ok_or("the document can't be read")?; + let mut cos = editor.cos.clone(); + let n = pdfcraft_model::pages(&cos).len(); + if pdfcraft_edit::remove_marks(&mut cos, &(0..n).collect::>(), kind).map_err(|e| e.to_string())? == 0 { + return Ok(None); + } + let bytes = Arc::new(pdfcraft_cos::write_incremental(&cos, &pdfcraft_cos::SaveOptions::default()).map_err(|e| e.to_string())?); + Ok(Some(UnmarkedPages { + bytes, + config: self.config.clone(), + parsed: None, + #[cfg(test)] + builds: 0, + })) + })? + } + /// An added item alone, on a transparent page: what follows the pointer while it is dragged, /// so the page under its first place doesn't come along. Read-only, like /// [`Self::added_content_preview`]. diff --git a/crates/engine/src/text_script.rs b/crates/engine/src/text_script.rs new file mode 100644 index 0000000000..04866eda60 --- /dev/null +++ b/crates/engine/src/text_script.rs @@ -0,0 +1,272 @@ +//! Superscript and subscript as Unicode characters (Edit a PDF ▸ Format text): `2` ↔ `²` and +//! `2` ↔ `₂`. +//! +//! The text keeps its font, size and baseline; its characters change to the forms Unicode encodes +//! for raised and lowered text, so the font must have those glyphs, and extracted text contains +//! them. Characters Unicode has no such form for are left as they are. +//! +//! A selection mixing plain and target-script characters is normalized: `H₂2` becomes `H₂₂`, +//! then `H22` on the next subscript toggle; superscript follows the same policy. Two toggles +//! restore uniformly plain or uniformly scripted input, but do not reconstruct an original +//! mixture. Restoring that exact prior text requires the caller's edit history (Undo). + +use std::ops::Range; + +/// Raised or lowered text. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Script { + Super, + Sub, +} + +/// Each character Unicode raises, and its superscript form. +const SUPERSCRIPT: &[(char, char)] = &[ + ('0', '⁰'), + ('1', '¹'), + ('2', '²'), + ('3', '³'), + ('4', '⁴'), + ('5', '⁵'), + ('6', '⁶'), + ('7', '⁷'), + ('8', '⁸'), + ('9', '⁹'), + ('+', '⁺'), + ('−', '⁻'), + ('=', '⁼'), + ('(', '⁽'), + (')', '⁾'), + ('a', 'ᵃ'), + ('b', 'ᵇ'), + ('c', 'ᶜ'), + ('d', 'ᵈ'), + ('e', 'ᵉ'), + ('f', 'ᶠ'), + ('g', 'ᵍ'), + ('h', 'ʰ'), + ('i', 'ⁱ'), + ('j', 'ʲ'), + ('k', 'ᵏ'), + ('l', 'ˡ'), + ('m', 'ᵐ'), + ('n', 'ⁿ'), + ('o', 'ᵒ'), + ('p', 'ᵖ'), + ('r', 'ʳ'), + ('s', 'ˢ'), + ('t', 'ᵗ'), + ('u', 'ᵘ'), + ('v', 'ᵛ'), + ('w', 'ʷ'), + ('x', 'ˣ'), + ('y', 'ʸ'), + ('z', 'ᶻ'), + ('A', 'ᴬ'), + ('B', 'ᴮ'), + ('D', 'ᴰ'), + ('E', 'ᴱ'), + ('G', 'ᴳ'), + ('H', 'ᴴ'), + ('I', 'ᴵ'), + ('J', 'ᴶ'), + ('K', 'ᴷ'), + ('L', 'ᴸ'), + ('M', 'ᴹ'), + ('N', 'ᴺ'), + ('O', 'ᴼ'), + ('P', 'ᴾ'), + ('R', 'ᴿ'), + ('T', 'ᵀ'), + ('U', 'ᵁ'), + ('V', 'ⱽ'), + ('W', 'ᵂ'), +]; + +/// Each character Unicode lowers, and its subscript form. +const SUBSCRIPT: &[(char, char)] = &[ + ('0', '₀'), + ('1', '₁'), + ('2', '₂'), + ('3', '₃'), + ('4', '₄'), + ('5', '₅'), + ('6', '₆'), + ('7', '₇'), + ('8', '₈'), + ('9', '₉'), + ('+', '₊'), + ('−', '₋'), + ('=', '₌'), + ('(', '₍'), + (')', '₎'), + ('a', 'ₐ'), + ('e', 'ₑ'), + ('h', 'ₕ'), + ('i', 'ᵢ'), + ('j', 'ⱼ'), + ('k', 'ₖ'), + ('l', 'ₗ'), + ('m', 'ₘ'), + ('n', 'ₙ'), + ('o', 'ₒ'), + ('p', 'ₚ'), + ('r', 'ᵣ'), + ('s', 'ₛ'), + ('t', 'ₜ'), + ('u', 'ᵤ'), + ('v', 'ᵥ'), + ('x', 'ₓ'), +]; + +impl Script { + fn pairs(self) -> &'static [(char, char)] { + match self { + Script::Super => SUPERSCRIPT, + Script::Sub => SUBSCRIPT, + } + } + + /// `c` raised or lowered, or `None` when Unicode has no distinct reversible form for it. + /// Mathematical minus U+2212 has a form; ASCII hyphen-minus stays unchanged to avoid a lossy collision. + pub fn form(self, c: char) -> Option { + self.pairs().iter().find(|(plain, _)| *plain == c).map(|&(_, form)| form) + } + + /// The character `c` is the raised or lowered form of, or `None` when it is no such form. + pub fn plain(self, c: char) -> Option { + self.pairs().iter().find(|(_, form)| *form == c).map(|&(plain, _)| plain) + } +} + +/// `range` (UTF-8 bytes of `text`) clamped to it and widened to whole characters, or all of `text` +/// when there is none. +fn span(text: &str, range: Option<&Range>) -> Range { + let Some(range) = range else { return 0..text.len() }; + let mut start = range.start.min(text.len()); + while !text.is_char_boundary(start) { + start = start.saturating_sub(1); + } + let mut end = range.end.min(text.len()); + while !text.is_char_boundary(end) { + end = end.saturating_add(1); + } + start..end +} + +/// Whether the characters of `text` in `range` (UTF-8 bytes; all of it when `None`) are already in +/// `script`: at least one of them is, and none of the others could be. Characters Unicode has no +/// form for (a space, a `q`) don't count toward that state. +pub fn is_in(text: &str, range: Option<&Range>, script: Script) -> bool { + let target = text.get(span(text, range)).unwrap_or_default(); + target.chars().any(|c| script.plain(c).is_some()) && target.chars().all(|c| script.plain(c).is_some() || script.form(c).is_none()) +} + +/// Superscript or Subscript on the characters of `text` in `range` (UTF-8 bytes; all of it when +/// `None`): back to plain characters when they are already in `script` ([`is_in`]), and otherwise +/// each character that has a form takes it. Returns the new text and the range the converted +/// characters now take, or `None` when nothing changes. +/// +/// Mixed plain/scripted input normalizes to the target script and then to plain text; for +/// example, `H₂2` → `H₂₂` → `H22`. This stateless conversion does not remember the original +/// mixture. Use edit history to restore it, rather than treating a second toggle as Undo. +pub fn toggle(text: &str, range: Option<&Range>, script: Script) -> Option<(String, Range)> { + let span = span(text, range); + let target = text.get(span.clone()).filter(|t| !t.is_empty())?; + let converted: String = if is_in(text, Some(&span), script) { + target.chars().map(|c| script.plain(c).unwrap_or(c)).collect() + } else { + target.chars().map(|c| script.form(c).unwrap_or(c)).collect() + }; + if converted == target { + return None; + } + let mut out = String::with_capacity(text.len().saturating_add(converted.len())); + out.push_str(text.get(..span.start).unwrap_or_default()); + out.push_str(&converted); + out.push_str(text.get(span.end..).unwrap_or_default()); + let end = span.start.saturating_add(converted.len()); + Some((out, span.start..end)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_form_toggles_back_to_its_character() { + for script in [Script::Super, Script::Sub] { + for &(plain, form) in script.pairs() { + let s = plain.to_string(); + let (raised, range) = toggle(&s, None, script).unwrap(); + assert_eq!(raised, form.to_string()); + assert_eq!(range, 0..raised.len()); + assert_eq!(toggle(&raised, None, script).unwrap().0, s, "{script:?} {plain}"); + } + } + assert_eq!(toggle("-", None, Script::Super), None); + assert_eq!(toggle("-", None, Script::Sub), None); + } + + #[test] + fn review_script_round_trips_preserve_both_minus_characters() { + for script in [Script::Super, Script::Sub] { + let text = "−2 -3"; + let once = toggle(text, None, script).unwrap().0; + assert_eq!(toggle(&once, None, script).unwrap().0, text); + } + } + + #[test] + fn text_with_characters_unicode_cannot_raise_toggles_back() { + // q has no superscript form and Q no subscript one; the space neither. + for (script, text, toggled) in [(Script::Super, "x2 q", "ˣ² q"), (Script::Sub, "CO2 Q", "CO₂ Q")] { + let (once, _) = toggle(text, None, script).unwrap(); + assert_eq!(once, toggled); + assert!(is_in(&once, None, script)); + assert_eq!(toggle(&once, None, script).unwrap().0, text, "{script:?}"); + } + // Nothing to raise: nothing changes. + assert_eq!(toggle("q Q", None, Script::Super), None); + assert!(!is_in("q Q", None, Script::Super)); + } + + #[test] + fn a_range_converts_only_its_characters_and_follows_them() { + let (text, range) = toggle("E = mc2", Some(&(6..7)), Script::Super).unwrap(); + assert_eq!((text.as_str(), range.clone()), ("E = mc²", 6..8)); + assert_eq!(toggle(&text, Some(&range), Script::Super).unwrap(), ("E = mc2".to_string(), 6..7)); + // A mixed range takes the form throughout. + assert_eq!(toggle("H₂2", None, Script::Sub).unwrap().0, "H₂₂"); + assert!(!is_in("H₂2", None, Script::Sub)); + } + + #[test] + fn mixed_plain_and_scripted_text_normalizes_instead_of_round_tripping() { + for (script, mixed, uniform, plain) in [(Script::Super, "Q²2", "Q²²", "Q22"), (Script::Sub, "H₂2", "H₂₂", "H22")] { + assert!(!is_in(mixed, None, script)); + let (once, range) = toggle(mixed, None, script).unwrap(); + assert_eq!(once, uniform); + assert_eq!(range, 0..uniform.len()); + assert!(is_in(&once, None, script)); + let (twice, range) = toggle(&once, None, script).unwrap(); + assert_eq!(twice, plain); + assert_eq!(range, 0..plain.len()); + assert_ne!(twice, mixed, "a second toggle normalizes the original mixture"); + assert_eq!(toggle(&twice, None, script).unwrap().0, uniform); + } + } + + #[test] + fn hostile_ranges_are_clamped_to_whole_characters() { + // Byte 1 is inside ²: widened to the whole character on each side. + assert_eq!(toggle("²x", Some(&(1..1)), Script::Super).unwrap(), ("2x".to_string(), 0..1)); + assert_eq!(toggle("²x", Some(&(2..2)), Script::Super), None); + assert_eq!(toggle("x2", Some(&(1..usize::MAX)), Script::Super).unwrap(), ("x²".to_string(), 1..3)); + assert_eq!(toggle("x2", Some(&(5..9)), Script::Super), None); + #[allow(clippy::reversed_empty_ranges)] + let backwards = 2..1; + assert_eq!(toggle("x2", Some(&backwards), Script::Super), None); + assert_eq!(toggle("", None, Script::Sub), None); + assert!(!is_in("", None, Script::Sub)); + } +} diff --git a/crates/engine/src/text_style_bookmarks.rs b/crates/engine/src/text_style_bookmarks.rs new file mode 100644 index 0000000000..5969188d67 --- /dev/null +++ b/crates/engine/src/text_style_bookmarks.rs @@ -0,0 +1,229 @@ +//! Bookmarks from text styles (#1693): a document's headings are often set apart only by how +//! they look (chapter titles in one font at one size, sections in another), not tagged as +//! headings, so New Bookmarks from Structure finds nothing. [`styles`] lists the styles the +//! text is set in; [`headings`] finds the lines in the styles chosen for each bookmark level. +//! +//! A style is a font (without its subset tag: `ABCDEF+Garamond` and `GHIJKL+Garamond` are +//! subsets of one font), the size the text is shown at, and whether it is bold and italic. +//! Lines are taken in reading order down each page: top to bottom, then left to right. + +use pdfcraft_cos::Document; +use pdfcraft_organize::OutlineEntry; + +/// How a line of text looks. +#[derive(Clone, Debug, PartialEq)] +pub struct TextStyle { + /// The font's name, without a subset tag. + pub font: String, + /// The size the text is shown at, in points (rounded to a tenth). + pub size: f64, + pub bold: bool, + pub italic: bool, +} + +impl TextStyle { + /// Whether `other` is this style. Sizes within a quarter point match, as the same heading + /// size is often written with rounding differences (12 and 11.96). + pub(crate) fn matches(&self, other: &TextStyle) -> bool { + self.font == other.font && self.bold == other.bold && self.italic == other.italic && (self.size - other.size).abs() <= SIZE_TOLERANCE + } +} + +/// How far apart two sizes may be and still be one style. +const SIZE_TOLERANCE: f64 = 0.25; + +/// A style and how much text is set in it. +#[derive(Clone, Debug, PartialEq)] +pub struct TextStyleUse { + pub style: TextStyle, + /// Lines in this style. + pub lines: usize, + /// Pages they are on. + pub pages: usize, + /// The first line in it, cut to [`SAMPLE_CHARS`] characters. + pub sample: String, +} + +/// The most pages read: a longer document is listed and searched by its first pages. +pub const MAX_PAGES: usize = 5000; +/// The most bookmarks made at once. +pub const MAX_BOOKMARKS: usize = 10_000; +/// The longest bookmark title kept, in characters (a heading run on into a paragraph). +const MAX_TITLE_CHARS: usize = 300; +/// The longest sample shown for a style. +const SAMPLE_CHARS: usize = 60; +/// The most styles listed, the largest first. +const MAX_STYLES: usize = 200; + +/// One line of text in reading order. +struct Line { + page: usize, + text: String, + style: TextStyle, + /// Top, bottom, left and right of its box in user space. + top: f64, + bottom: f64, + left: f64, + right: f64, +} + +/// `name` without a subset tag: six capital letters and a `+` (ISO 32000-2 §9.9.2). +fn without_subset_tag(name: &str) -> &str { + match name.split_once('+') { + Some((tag, rest)) if tag.len() == 6 && tag.bytes().all(|b| b.is_ascii_uppercase()) && !rest.is_empty() => rest, + _ => name, + } +} + +/// The text lines of `doc`'s first [`MAX_PAGES`] pages, each page's in reading order. Text that +/// is only whitespace, or whose size can't be read, is left out. +fn lines(doc: &Document) -> Vec { + let pages = pdfcraft_organize::page_count(doc).unwrap_or(0).min(MAX_PAGES); + let mut out = Vec::new(); + for page in 0..pages { + let Ok(found) = pdfcraft_edit::text_lines(doc, page) else { continue }; + let mut on_page: Vec = found + .into_iter() + .filter_map(|l| { + let text = l.text.split_whitespace().collect::>().join(" "); + // `size` is the size the line is shown at in user space (the `Tf` size times the + // text rendering matrix), so `1 Tf` with a 14× text matrix is 14. + let size = l.size; + let [x0, y0, x1, y1] = l.rect; + let ok = !text.is_empty() && size.is_finite() && size > 0.0 && [x0, y0, x1, y1].iter().all(|v| v.is_finite()); + ok.then(|| Line { + page, + text, + style: TextStyle { + font: without_subset_tag(&l.base_font).to_string(), + size: (size * 10.0).round() / 10.0, + bold: l.bold, + italic: l.italic, + }, + top: y0.max(y1), + bottom: y0.min(y1), + left: x0.min(x1), + right: x0.max(x1), + }) + }) + .collect(); + // Down the page; along each row from the left. Boxes of one row differ by a fraction + // of a point, so rows are compared at a tenth of a point. + on_page.sort_by(|a, b| (b.top * 10.0).round().total_cmp(&(a.top * 10.0).round()).then(a.left.total_cmp(&b.left))); + out.extend(on_page); + } + out +} + +/// The styles `doc`'s text is set in, the largest size first (then the most used): headings +/// are usually larger than the text around them. +pub fn styles(doc: &Document) -> Vec { + let mut uses: Vec<(TextStyleUse, Option)> = Vec::new(); + // The styles by font, weight, slant and size in quarter points, so a line is matched against + // the few styles near its size rather than every style found (a page can hold thousands). + // A size matches within a quarter point: the bucket's neighbours are looked at too. + let mut index: std::collections::HashMap<(String, bool, bool, i64), Vec> = std::collections::HashMap::new(); + let bucket = |size: f64| (size / SIZE_TOLERANCE).floor() as i64; + for line in lines(doc) { + let key = |b: i64| (line.style.font.clone(), line.style.bold, line.style.italic, b); + let b = bucket(line.style.size); + let found = [b.saturating_sub(1), b, b.saturating_add(1)] + .into_iter() + .filter_map(|b| index.get(&key(b))) + .flatten() + .copied() + .find(|&i| uses.get(i).is_some_and(|(u, _)| u.style.matches(&line.style))); + match found.and_then(|i| uses.get_mut(i)) { + Some((u, last_page)) => { + u.lines = u.lines.saturating_add(1); + if *last_page != Some(line.page) { + u.pages = u.pages.saturating_add(1); + *last_page = Some(line.page); + } + } + None => { + let sample = line.text.chars().take(SAMPLE_CHARS).collect(); + index.entry(key(b)).or_default().push(uses.len()); + uses.push((TextStyleUse { style: line.style, lines: 1, pages: 1, sample }, Some(line.page))); + } + } + } + let mut uses: Vec = uses.into_iter().map(|(u, _)| u).collect(); + uses.sort_by(|a, b| b.style.size.total_cmp(&a.style.size).then(b.lines.cmp(&a.lines))); + uses.truncate(MAX_STYLES); + uses +} + +/// The bookmarks for the lines in `levels`' styles: a line in `levels[0]`'s style is a level-1 +/// bookmark, `levels[1]`'s a level-2 one under the level-1 bookmark before it, and so on. A +/// heading set on several lines (one after another in the same style, close below each other) +/// is one bookmark. Each goes to the top of its heading. +pub fn headings(doc: &Document, levels: &[TextStyle]) -> Vec { + let mut entries: Vec = Vec::new(); + // The heading the previous line belonged to: its level, and the top, bottom, left and right + // of its last line. + let mut open: Option<(usize, f64, f64, f64, f64)> = None; + for line in lines(doc) { + let Some(level) = levels.iter().position(|s| s.matches(&line.style)) else { + open = None; + continue; + }; + // A heading goes on to the next row down, close below its last line and under it. A line + // on the same row (two headings side by side), or in another column, is a heading of + // its own. + let continues = open.is_some_and(|(l, top, bottom, left, right)| { + let size = line.style.size; + let under = line.left < right && left < line.right; + l == level && line.top < top - size * 0.5 && bottom - line.top < size * 0.6 && under + }) && entries.last().is_some_and(|e| e.page == line.page); + if continues { + if let Some(e) = entries.last_mut() + && e.title.chars().count() < MAX_TITLE_CHARS + { + e.title.push(' '); + e.title.push_str(&line.text); + e.title = e.title.chars().take(MAX_TITLE_CHARS).collect(); + } + } else { + if entries.len() >= MAX_BOOKMARKS { + break; + } + // The level counts from 1; the position in `levels` is at most its length, which a + // caller keeps small (a few levels), so it fits a u8. + let level = u8::try_from(level.saturating_add(1)).unwrap_or(u8::MAX); + entries.push(OutlineEntry { + level, + title: line.text.chars().take(MAX_TITLE_CHARS).collect(), + page: line.page, + element: None, + top: Some(line.top), + }); + } + open = Some((level, line.top, line.bottom, line.left, line.right)); + } + entries +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn subset_tags_are_left_out_of_the_font_name() { + assert_eq!(without_subset_tag("ABCDEF+Garamond-Bold"), "Garamond-Bold"); + assert_eq!(without_subset_tag("Garamond-Bold"), "Garamond-Bold"); + assert_eq!(without_subset_tag("ABCDE+Garamond"), "ABCDE+Garamond", "a tag is six letters"); + assert_eq!(without_subset_tag("abcdef+Garamond"), "abcdef+Garamond", "capital letters"); + assert_eq!(without_subset_tag("ABCDEF+"), "ABCDEF+", "nothing after the tag"); + assert_eq!(without_subset_tag("ÄBCDEF+x"), "ÄBCDEF+x"); + } + + #[test] + fn styles_match_within_a_quarter_point() { + let s = |size| TextStyle { font: "Garamond".into(), size, bold: true, italic: false }; + assert!(s(12.0).matches(&s(11.8))); + assert!(!s(12.0).matches(&s(11.7))); + assert!(!s(12.0).matches(&TextStyle { bold: false, ..s(12.0) })); + assert!(!s(12.0).matches(&TextStyle { font: "Times".into(), ..s(12.0) })); + } +} diff --git a/crates/engine/src/xfa.rs b/crates/engine/src/xfa.rs index dd011d47fc..f20c82f6c6 100644 --- a/crates/engine/src/xfa.rs +++ b/crates/engine/src/xfa.rs @@ -388,6 +388,7 @@ impl<'a, 'b> Runner<'a, 'b> { } } + /// Keep console `lines`, up to [`MAX_CONSOLE_PER_EVENT`] for one event; the rest are counted. fn console(&mut self, lines: impl IntoIterator) { for l in lines { if self.run.out.console.len() < MAX_CONSOLE_PER_EVENT { @@ -398,7 +399,8 @@ impl<'a, 'b> Runner<'a, 'b> { } } - fn alert(&mut self, m: String) { + /// Show message `m`, up to [`MAX_ALERTS_PER_EVENT`] for one event; the rest are counted. + fn alert(&mut self, m: crate::js::Message) { if self.run.out.alerts.len() < MAX_ALERTS_PER_EVENT { self.run.out.alerts.push(m); } else { @@ -847,7 +849,7 @@ pub(crate) fn on_changes(doc: &mut pdfcraft_cos::Document, tpl: &Template, names for ev in r.events_of(&som, "validate") { let Some(o) = r.run(&ev, &value)? else { continue }; if (o.result_bool == Some(false) || o.error.is_some()) && !value.trim().is_empty() { - r.alert(ev.message.clone().unwrap_or_else(|| format!("{name}: the value is not valid"))); + r.alert(crate::js::Message::plain(ev.message.clone().unwrap_or_else(|| format!("{name}: the value is not valid")))); } } } diff --git a/crates/export/src/as_docx.rs b/crates/export/src/as_docx.rs new file mode 100644 index 0000000000..ab7451dc0e --- /dev/null +++ b/crates/export/src/as_docx.rs @@ -0,0 +1,364 @@ +use super::Zip; +use super::as_html::esc; +use super::layout::{Image, Item, Page, Table, body_size, items}; + +/// A colour's 8-bit sRGB components (NaN and out-of-range values clamp; NaN reads as 0). +pub(crate) fn rgb8(c: [f64; 3]) -> [u8; 3] { + // The value is clamped to 0..=255 before the cast, so it cannot truncate. + c.map(|v| if v.is_finite() { (v.clamp(0.0, 1.0) * 255.0).round() as u8 } else { 0 }) +} + +/// The colour as `RRGGBB`, or `None` for black (the default every format already uses). +pub(crate) fn hex(c: [f64; 3]) -> Option { + let [r, g, b] = rgb8(c); + (r | g | b != 0).then(|| format!("{r:02X}{g:02X}{b:02X}")) +} + +/// A formatted text run (shared by paragraphs and table cells). +fn run_xml(text: &str, size: f64, bold: bool, italic: bool, color: [f64; 3]) -> String { + let mut rpr = String::new(); + if bold { + rpr.push_str(""); + } + if italic { + rpr.push_str(""); + } + if let Some(h) = hex(color) { + rpr.push_str(&format!("")); + } + let half_points = if size.is_finite() { (size * 2.0).round().clamp(2.0, 3276.0) as i64 } else { 24 }; + rpr.push_str(&format!("")); + format!("{rpr}{}", esc(text)) +} + +/// One Word table: explicit absent borders (alignment alone cannot prove PDF rules), a grid sized +/// from the detected column edges, `gridSpan` for spanning cells, empty cells for holes. +pub(crate) fn docx_table(t: &Table) -> String { + let mut edges = t.cols.clone(); + edges.push(t.rect[2]); + let widths: Vec = edges.windows(2).map(|w| ((w[1] - w[0]).max(20.0) * 20.0).round().clamp(60.0, 31680.0) as i64).collect(); + let mut s = String::from(""); + for edge in ["top", "left", "bottom", "right", "insideH", "insideV"] { + s.push_str(&format!("")); + } + s.push_str(""); + for w in &widths { + s.push_str(&format!("")); + } + s.push_str(""); + for row in &t.rows { + s.push_str(""); + let mut column = 0usize; + for c in row { + let span = if c.span > 1 { format!("", c.span) } else { String::new() }; + let w: i64 = widths.iter().skip(column).take(c.span).sum(); + s.push_str(&format!( + "{span}{}", + run_xml(&c.text, c.size, c.bold, c.italic, c.color) + )); + column = column.saturating_add(c.span); + } + // Pad the grid so every row covers all columns (Word rejects short rows). + for _ in column..t.cols.len() { + s.push_str(""); + } + s.push_str(""); + } + s.push_str(""); + s +} + +/// Pages with rotated text, or text that actually overlaps an image or an oversized +/// (watermark-like) text block, cannot be put in paragraph flow without changing their layout. +/// Keep those pages as editable, absolutely positioned text boxes. Every other page, including +/// one with a logo or a big title that sits clear of the text, flows as paragraphs and tables. +fn positioned(page: &Page) -> bool { + if page.blocks.iter().any(|b| b.rotation.abs() > 0.1) { + return true; + } + let body = body_size(std::slice::from_ref(page)).max(1.0); + let overlays = page + .images + .iter() + .map(|im| (im.rect, None)) + .chain(page.blocks.iter().enumerate().filter(|(_, b)| b.size >= 2.0 * body).map(|(i, b)| (b.rect, Some(i)))); + // Bound the pairwise test on hostile pages (many overlays times many blocks): past the + // budget, keep the default flowing layout. + let mut budget: usize = 1_000_000; + for (r, own) in overlays { + for (i, b) in page.blocks.iter().enumerate() { + if budget == 0 { + return false; + } + budget -= 1; + if Some(i) != own && overlaps(r, b.rect) { + return true; + } + } + } + false +} + +/// Whether two [x0, y0, x1, y1] boxes share more than a hairline (0.5 pt each way); boxes that +/// only touch, or hostile non-finite coordinates, do not count. +fn overlaps(a: [f64; 4], b: [f64; 4]) -> bool { + let w = a[2].min(b[2]) - a[0].max(b[0]); + let h = a[3].min(b[3]) - a[1].max(b[1]); + w.is_finite() && h.is_finite() && w > 0.5 && h > 0.5 +} + +/// Bound every value written into a shape's style, including hostile PDF coordinates. +fn layout_number(n: f64) -> f64 { + if n.is_finite() { n.clamp(-1584.0, 1584.0) } else { 0.0 } +} + +/// Most images a Word export embeds, and most image bytes (the zip writer has 32-bit sizes and +/// a 16-bit entry count; a hostile PDF must not be able to overflow them). +pub(crate) const MAX_MEDIA: usize = 1000; + +const MAX_MEDIA_BYTES: usize = 512 << 20; + +/// Whether `im` can still be embedded: it has data, and the package stays within its limits. +fn room_for(media: &[(String, &Image)], im: &Image) -> bool { + !im.bytes.is_empty() + && media.len() < MAX_MEDIA + && media.iter().map(|(_, m)| m.bytes.len()).fold(im.bytes.len(), usize::saturating_add) <= MAX_MEDIA_BYTES +} + +/// Points as EMU (12700 per point); non-finite values are 0. +fn emu(pt: f64) -> i64 { + if pt.is_finite() { (pt * 12700.0).round() as i64 } else { 0 } +} + +/// The `a:graphic` of one picture (DrawingML), rotated by `rotation` degrees when given. +fn picture_xml(n: usize, name: &str, (cx, cy): (i64, i64), rotation: Option) -> String { + let rot = rotation.map_or(String::new(), |r| format!(" rot=\"{}\"", (r * 60000.0).round() as i64)); + format!( + "\ +\ +\ +\ +\ +" + ) +} + +fn positioned_page<'a>(page: &'a Page, media: &mut Vec<(String, &'a Image)>, shape_id: &mut usize) -> String { + // One 1 pt paragraph anchors the whole page. Shapes never reserve body space, so even a + // page-sized watermark cannot push the invoice footer onto another page. + let mut s = String::from(""); + let body = body_size(std::slice::from_ref(page)).max(1.0); + for b in &page.blocks { + *shape_id = shape_id.saturating_add(1); + let rotation = layout_number(-b.rotation).rem_euclid(360.0); + let (sin, cos) = rotation.to_radians().sin_cos(); + let (bw, bh) = (layout_number(b.rect[2] - b.rect[0]).max(1.0), layout_number(b.rect[3] - b.rect[1]).max(1.0)); + let h = if b.rotation.abs() > 0.1 { layout_number(b.size).max(1.0) } else { bh }; + // Recover the unrotated width from the axis-aligned box. Use the larger axis so + // quarter turns and 45-degree watermarks do not divide by (nearly) zero. + let w = if b.rotation.abs() <= 0.1 { + bw + } else if cos.abs() >= sin.abs() { + (bw - h * sin.abs()) / cos.abs() + } else { + (bh - h * cos.abs()) / sin.abs() + }; + let w = layout_number(w).max(1.0); + let (x, y) = (layout_number(b.rect[0] + (bw - w) / 2.0), layout_number(page.height - b.rect[3] + (bh - h) / 2.0)); + let z = if b.size >= 2.0 * body { -2 } else { 1 }; + let style = format!( + "position:absolute;margin-left:{x:.3}pt;margin-top:{y:.3}pt;width:{w:.3}pt;height:{h:.3}pt;rotation:{rotation:.3};z-index:{z};mso-position-horizontal-relative:page;mso-position-vertical-relative:page" + ); + s.push_str(&format!( + "{}", + shape_id, run_xml(&b.text, b.size, b.bold, b.italic, b.color) + )); + } + for im in &page.images { + if !room_for(media, im) { + continue; + } + *shape_id = shape_id.saturating_add(1); + let n = media.len().saturating_add(1); + let (w, h) = (layout_number(im.size[0]).max(1.0), layout_number(im.size[1]).max(1.0)); + let (x, y) = (layout_number((im.rect[0] + im.rect[2] - w) / 2.0), layout_number(page.height - (im.rect[1] + im.rect[3] + h) / 2.0)); + let rotation = layout_number(-im.rotation).rem_euclid(360.0); + let name = format!("image{n}.{}", im.ext); + let pic = picture_xml(n, &name, (emu(w), emu(h)), Some(rotation)); + // A DrawingML anchor, the way Word itself stores a picture placed behind text: a VML + // rectangle with an image does not show the picture. + s.push_str(&format!( + "\ +{}{}\ +", + n.saturating_add(1), + emu(x), + emu(y), + emu(w), + emu(h), + )); + s.push_str(&pic); + s.push_str(""); + media.push((name, im)); + } + s.push_str(""); + s +} + +fn word_page_size(page: &Page) -> (i64, i64) { + let twips = |pt: f64| if pt.is_finite() { (pt * 20.0).round().clamp(144.0, 31680.0) as i64 } else { 12240 }; + (twips(page.width), twips(page.height)) +} + +fn section(page: &Page) -> String { + let (pw, ph) = word_page_size(page); + let (mx, my) = ((pw / 8).min(1440), (ph / 8).min(1440)); + format!( + "", + my / 2, + my / 2 + ) +} + +/// The page colour of a Word document: Word has one background for the whole document, so it +/// is written only when every page has the same, non-white background (#2992). +fn word_background(pages: &[Page]) -> Option { + let first = rgb8(pages.first()?.background?); + let same = pages.iter().all(|p| p.background.map(rgb8) == Some(first)); + (same && first != [255, 255, 255]).then(|| format!("{:02X}{:02X}{:02X}", first[0], first[1], first[2])) +} + +/// A Word document (.docx, Office Open XML). Ordinary pages flow as paragraphs and tables; +/// pages with rotated text, or text that overlaps artwork, use floating shapes to retain their layout. +pub fn docx(pages: &[Page], title: &str) -> Vec { + let mut body = String::new(); + let mut media: Vec<(String, &Image)> = Vec::new(); + // Word needs a paragraph between adjacent tables and after the last one in the body. + let mut after_table = false; + let mut shape_id = 0usize; + for (page_index, page) in pages.iter().enumerate() { + let (pw, _) = word_page_size(page); + let text_w = (pw - 2 * (pw / 8).min(1440)) as f64 / 20.0; + if positioned(page) { + body.push_str(&positioned_page(page, &mut media, &mut shape_id)); + after_table = false; + } else { + for it in items(std::slice::from_ref(page)) { + match &it { + Item::Para(runs, lvl) => { + let style = match lvl { + 1 => "", + 2 => "", + _ => "", + }; + body.push_str(""); + body.push_str(style); + for r in runs { + body.push_str(&run_xml(&r.text, r.block.size, r.block.bold, r.block.italic, r.block.color)); + } + body.push_str(""); + } + Item::Table(t) => { + if after_table { + body.push_str(""); + } + body.push_str(&docx_table(t)); + } + Item::Img(im) => { + if room_for(&media, im) { + let n = media.len() + 1; + let name = format!("image{n}.{}", im.ext); + // Size on the page, at most the text width. + let (w, h) = ((im.rect[2] - im.rect[0]).max(1.0), (im.rect[3] - im.rect[1]).max(1.0)); + let k = (text_w / w).min(1.0); + let (cx, cy) = (emu(w * k), emu(h * k)); + body.push_str(&format!("")); + body.push_str(&picture_xml(n, &name, (cx, cy), None)); + body.push_str(""); + media.push((name, im)); + } + } + Item::PageBreak => body.push_str(""), + } + after_table = matches!(it, Item::Table(_)); + } + } + // Section breaks carry this source page's size and create exactly one next page. + // The last section must be a direct child of the body (OOXML 17.6.17). + if page_index + 1 < pages.len() { + body.push_str(&format!( + "{}", + section(page) + )); + } else { + if after_table || page.blocks.is_empty() && page.images.is_empty() { + body.push_str(""); + } + body.push_str(§ion(page)); + } + after_table = false; + } + // Word shows a document background only when its settings say so (`settings.xml` below). + let page_colour = word_background(pages); + let background = page_colour.as_deref().map(|c| format!("")).unwrap_or_default(); + let doc = format!( + "\n\ +{background}{body}\ +" + ); + let mut types = String::from( + "\n\ +\ +\ +\ +\ +", + ); + if page_colour.is_some() { + types.push_str( + "", + ); + } + types.push_str(""); + let rels = "\n\ +\ +"; + let mut doc_rels = String::from( + "\n\ +", + ); + for (i, (name, _)) in media.iter().enumerate() { + doc_rels.push_str(&format!( + "", + i + 1 + )); + } + if page_colour.is_some() { + doc_rels.push_str(""); + } + doc_rels.push_str(""); + let styles = "\n\ +\ +\ +\ +"; + let core = format!( + "\n{}", + esc(title) + ); + let mut z = Zip::default(); + z.add("[Content_Types].xml", types.as_bytes(), true); + z.add("_rels/.rels", rels.as_bytes(), true); + z.add("docProps/core.xml", core.as_bytes(), true); + z.add("word/document.xml", doc.as_bytes(), true); + z.add("word/styles.xml", styles.as_bytes(), true); + z.add("word/_rels/document.xml.rels", doc_rels.as_bytes(), true); + if page_colour.is_some() { + z.add("word/settings.xml", b"\n", true); + } + for (name, im) in &media { + z.add(&format!("word/media/{name}"), &im.bytes, false); + } + z.finish() +} diff --git a/crates/export/src/as_html.rs b/crates/export/src/as_html.rs new file mode 100644 index 0000000000..3bc6709e69 --- /dev/null +++ b/crates/export/src/as_html.rs @@ -0,0 +1,115 @@ +use super::as_docx::{hex, rgb8}; +use super::layout::{Item, Page, items}; + +/// Whether a character may appear in the output. XML 1.0 forbids the C0 controls other than tab, +/// line feed and carriage return, and U+FFFE/U+FFFF. Text extracted from PDFs often holds them +/// (fonts without a usable `/ToUnicode`), and Word refuses a whole document over one (#72). +pub(crate) fn allowed(c: char) -> bool { + matches!(c, '\t' | '\n' | '\r') || !(c.is_ascii_control() || c == '\u{FFFE}' || c == '\u{FFFF}') +} + +pub(crate) fn esc(s: &str) -> String { + let mut o = String::with_capacity(s.len()); + for c in s.chars().filter(|c| allowed(*c)) { + match c { + '&' => o.push_str("&"), + '<' => o.push_str("<"), + '>' => o.push_str(">"), + '"' => o.push_str("""), + c => o.push(c), + } + } + o +} + +pub(crate) fn base64(data: &[u8]) -> String { + const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut s = String::with_capacity(data.len().div_ceil(3) * 4); + for c in data.chunks(3) { + let n = (c[0] as u32) << 16 | (*c.get(1).unwrap_or(&0) as u32) << 8 | *c.get(2).unwrap_or(&0) as u32; + s.push(A[(n >> 18) as usize & 63] as char); + s.push(A[(n >> 12) as usize & 63] as char); + s.push(if c.len() > 1 { A[(n >> 6) as usize & 63] as char } else { '=' }); + s.push(if c.len() > 2 { A[n as usize & 63] as char } else { '=' }); + } + s +} + +/// One HTML file: headings and paragraphs, tables as real `` elements, images inline, a +/// rule between pages. +pub fn html(pages: &[Page], title: &str) -> String { + html_with_mode(pages, title, false) +} + +/// A simple HTML export with no CSS; page layout and colours are omitted. +pub fn html_simple(pages: &[Page], title: &str) -> String { + html_with_mode(pages, title, true) +} + +fn html_with_mode(pages: &[Page], title: &str, simple: bool) -> String { + let mut s = format!( + "\n\n\n\n{}\n{}\n\n\n", + esc(title), + if simple { + "" + } else { + "" + } + ); + for it in items(pages) { + match it { + Item::Para(runs, lvl) => { + let Some(first) = runs.first().map(|r| r.block) else { continue }; + let mut t = String::new(); + for r in &runs { + let mut run = esc(&r.text); + if r.block.italic { + run = format!("{run}"); + } + if r.block.bold && lvl == 0 { + run = format!("{run}"); + } + // The paragraph has its first run's colour; a run in another colour says so. + let colour = rgb8(r.block.color); + if !simple && colour != rgb8(first.color) { + let [red, green, blue] = colour; + run = format!("{run}"); + } + t.push_str(&run); + } + let style = if simple { String::new() } else { hex(first.color).map_or(String::new(), |h| format!(" style=\"color:#{h}\"")) }; + match lvl { + 0 => s.push_str(&format!("{t}

\n")), + l => s.push_str(&format!("{t}\n")), + } + } + Item::Table(t) => { + s.push_str("
\n"); + for row in &t.rows { + s.push_str(""); + for c in row { + let mut body = esc(&c.text); + if c.italic { + body = format!("{body}"); + } + if c.bold { + body = format!("{body}"); + } + let span = if c.span > 1 { format!(" colspan=\"{}\"", c.span) } else { String::new() }; + let style = if simple { String::new() } else { hex(c.color).map_or(String::new(), |h| format!(" style=\"color:#{h}\"")) }; + s.push_str(&format!("{body}")); + } + s.push_str("\n"); + } + s.push_str("
\n"); + } + Item::Img(im) => { + let mime = if im.ext == "jpg" { "image/jpeg" } else { "image/png" }; + s.push_str(&format!("

\"\"

\n", base64(&im.bytes))); + } + Item::PageBreak => s.push_str("
\n"), + } + } + s.push_str("\n\n"); + s +} diff --git a/crates/export/src/as_markdown.rs b/crates/export/src/as_markdown.rs new file mode 100644 index 0000000000..a5c986c6b3 --- /dev/null +++ b/crates/export/src/as_markdown.rs @@ -0,0 +1,151 @@ +use super::as_html::allowed; +use super::layout::{Cell, Item, MAX_COLS, Page, items}; + +/// Markdown's deepest heading. +const MAX_HEADING: u8 = 6; + +/// `s` as Markdown text on one line: line breaks become spaces, control characters are dropped, +/// and every character Markdown would read as markup is escaped with a backslash. +fn md_text(s: &str) -> String { + let mut o = String::with_capacity(s.len()); + for c in s.chars().filter(|c| allowed(*c)) { + match c { + '\n' | '\r' | '\t' => o.push(' '), + '\\' | '`' | '*' | '_' | '[' | ']' | '<' | '>' | '#' | '|' | '~' | '!' | '&' => { + o.push('\\'); + o.push(c); + } + c => o.push(c), + } + } + o +} + +/// `line` with its start escaped where Markdown would read a list item there (`- `, `+ `, +/// `1. `, `1) `); the other block markers (`#`, `>`) are escaped by [`md_text`] already. +fn block_start(line: String) -> String { + let digits = line.bytes().take_while(u8::is_ascii_digit).count(); + match line.as_bytes().get(digits) { + Some(b'.' | b')') if digits > 0 => format!("{}\\{}", line.get(..digits).unwrap_or_default(), line.get(digits..).unwrap_or_default()), + Some(b'-' | b'+') if digits == 0 => format!("\\{line}"), + _ => line, + } +} + +/// `text` (already escaped) in bold and/or italic, its outer spaces kept outside. `open` and +/// `close` say whether `*` markers would be read as emphasis on that side (see [`marks`]); +/// where they would not, as in `foo` + bold `.` + `bar`, the run is wrapped in HTML tags, which +/// CommonMark takes inline. The text's own outer spaces count as a side that marks. +pub(crate) fn emphasis(text: &str, bold: bool, italic: bool, open: bool, close: bool) -> String { + let core = text.trim(); + if core.is_empty() || !(bold || italic) { + return text.to_string(); + } + let lead = text.get(..text.len().saturating_sub(text.trim_start().len())).unwrap_or_default(); + let trail = text.get(text.trim_end().len()..).unwrap_or_default(); + let markers = (open || !lead.is_empty()) && (close || !trail.is_empty()); + let (before, after) = match (markers, bold, italic) { + (true, true, true) => ("***", "***"), + (true, true, false) => ("**", "**"), + (true, false, _) => ("*", "*"), + (false, true, true) => ("", ""), + (false, true, false) => ("", ""), + (false, false, _) => ("", ""), + }; + format!("{lead}{before}{core}{after}{trail}") +} + +/// A paragraph's runs (text, bold, italic) as one line of Markdown. Neighbouring runs in the +/// same style are joined first, so two bold runs never meet as `****`. +pub(crate) fn paragraph(runs: &[(&str, bool, bool)]) -> String { + let mut merged: Vec<(String, bool, bool)> = Vec::new(); + for &(text, bold, italic) in runs { + match merged.last_mut() { + Some(last) if last.1 == bold && last.2 == italic => last.0.push_str(text), + _ => merged.push((text.to_string(), bold, italic)), + } + } + let texts: Vec = merged.iter().map(|(text, ..)| md_text(text)).collect(); + let mut out = String::new(); + for (k, ((_, bold, italic), text)) in merged.iter().zip(&texts).enumerate() { + let core = text.trim(); + let open = marks(core.chars().next(), out.chars().last()); + // Markers of a styled neighbour right next to these would join them into one run. + let close = match (merged.get(k.saturating_add(1)), texts.get(k.saturating_add(1))) { + (Some((_, b, i)), Some(next)) if (*b || *i) && !next.starts_with(char::is_whitespace) => false, + (_, next) => marks(core.chars().last(), next.and_then(|n| n.chars().next())), + }; + out.push_str(&emphasis(text, *bold, *italic, open, close)); + } + out +} + +/// Whether a `*` marker between the text's `edge` character and the `outside` one is read as +/// emphasis (CommonMark's flanking rules for `*`): always next to a space or the line's edge, +/// never next to another `*`, and otherwise unless the text's edge is punctuation while the +/// outside is a letter or digit. +fn marks(edge: Option, outside: Option) -> bool { + match (edge, outside) { + (_, None) => true, + (_, Some(o)) if o.is_whitespace() => true, + (_, Some('*')) => false, + (Some(e), Some(o)) => e.is_alphanumeric() || o.is_ascii_punctuation(), + (None, Some(_)) => false, + } +} + +fn cell(c: &Cell) -> String { + // A cell sits between `| ` and ` |`, so its markers always have a space outside. + emphasis(&md_text(&c.text), c.bold, c.italic, true, true) +} + +/// Markdown (CommonMark, with GitHub's pipe tables): headings, paragraphs with bold and italic, +/// tables with their first row as the header, and a rule between pages. Images are left out. +pub fn markdown(pages: &[Page]) -> String { + let mut blocks: Vec = Vec::new(); + for it in items(pages) { + match it { + Item::Para(runs, lvl) => { + // A heading's weight is the heading's own, as in the HTML export. + let runs: Vec<(&str, bool, bool)> = runs.iter().map(|r| (r.text.as_str(), r.block.bold && lvl == 0, r.block.italic)).collect(); + let text = paragraph(&runs); + let text = text.trim(); + if text.is_empty() { + continue; + } + blocks.push(match lvl { + 0 => block_start(text.to_string()), + l => format!("{} {text}", "#".repeat(usize::from(l.min(MAX_HEADING)))), + }); + } + Item::Table(t) => { + // Each row as wide as the widest; a cell spanning columns is followed by empty ones. + // Detected tables have at most MAX_COLS columns, and a span never counts for more. + let span = |c: &Cell| c.span.clamp(1, MAX_COLS); + let width = t.rows.iter().map(|r| r.iter().map(span).fold(0usize, usize::saturating_add)).max().unwrap_or(0); + if width == 0 { + continue; + } + let mut lines = Vec::with_capacity(t.rows.len().saturating_add(1)); + for (i, row) in t.rows.iter().enumerate() { + let mut cells: Vec = Vec::with_capacity(width); + for c in row { + cells.push(cell(c)); + cells.extend(std::iter::repeat_n(String::new(), span(c).saturating_sub(1))); + } + cells.resize(width, String::new()); + lines.push(format!("| {} |", cells.join(" | "))); + if i == 0 { + lines.push(format!("|{}", " --- |".repeat(width))); + } + } + blocks.push(lines.join("\n")); + } + Item::Img(_) => {} + Item::PageBreak => blocks.push("---".into()), + } + } + let mut s = blocks.join("\n\n"); + s.push('\n'); + s +} diff --git a/crates/export/src/as_rtf.rs b/crates/export/src/as_rtf.rs new file mode 100644 index 0000000000..61585d82fb --- /dev/null +++ b/crates/export/src/as_rtf.rs @@ -0,0 +1,127 @@ +use super::as_docx::rgb8; +use super::as_html::allowed; +use super::layout::{Block, Item, Page, count_lookup_steps, items}; +use std::collections::HashMap; + +fn rtf_text(s: &str) -> String { + let mut o = String::new(); + for c in s.chars().filter(|c| allowed(*c)) { + match c { + '\\' | '{' | '}' => { + o.push('\\'); + o.push(c); + } + c if (c as u32) < 128 => o.push(c), + c => { + // \uN with a ? fallback; UTF-16 units as signed 16-bit numbers. + let mut buf = [0u16; 2]; + for u in c.encode_utf16(&mut buf) { + o.push_str(&format!("\\u{}?", *u as i16)); + } + } + } + } + o +} + +/// Rich Text Format: paragraphs with their sizes and bold/italic, real table rows, page breaks +/// between pages (images are left out). +pub fn rtf(pages: &[Page]) -> String { + let items = items(pages); + // The colour table: entry 0 is the default (auto) colour, then each non-black colour once. + let mut colors: Vec<[u8; 3]> = Vec::new(); + let mut slot: HashMap<[u8; 3], usize> = HashMap::new(); + let mut note = |c: [f64; 3]| { + let c = rgb8(c); + count_lookup_steps(1); + if c != [0, 0, 0] && !slot.contains_key(&c) { + slot.insert(c, colors.len()); + colors.push(c); + } + }; + for it in &items { + match it { + Item::Para(runs, _) => runs.iter().for_each(|r| note(r.block.color)), + Item::Table(t) => t.rows.iter().flatten().for_each(|c| note(c.color)), + Item::Img(_) | Item::PageBreak => {} + } + } + // `\cfN` for a colour (nothing for black, which keeps the default). + let cf = |c: [f64; 3]| { + let c = rgb8(c); + count_lookup_steps(1); + slot.get(&c).map_or(String::new(), |i| format!("\\cf{}", i + 1)) + }; + let mut s = String::from("{\\rtf1\\ansi\\deff0{\\fonttbl{\\f0 Helvetica;}}\n"); + if !colors.is_empty() { + s.push_str("{\\colortbl;"); + for [r, g, b] in &colors { + s.push_str(&format!("\\red{r}\\green{g}\\blue{b};")); + } + s.push_str("}\n"); + } + for it in items { + match it { + Item::Para(runs, _) => { + // The first run's formatting is the paragraph's; each later run is a group that + // turns on or off what differs from it. + let fmt = |b: &Block, later: bool| { + let mut fmt = format!("\\fs{}", (b.size * 2.0).round() as i64); + if b.bold { + fmt.push_str("\\b"); + } else if later { + fmt.push_str("\\b0"); + } + if b.italic { + fmt.push_str("\\i"); + } else if later { + fmt.push_str("\\i0"); + } + let colour = cf(b.color); + fmt.push_str(if colour.is_empty() && later { "\\cf0" } else { &colour }); + fmt + }; + let mut runs = runs.iter(); + let Some(first) = runs.next() else { continue }; + s.push_str(&format!("{{\\pard{} {}", fmt(first.block, false), rtf_text(&first.text))); + for r in runs { + s.push_str(&format!("{{{} {}}}", fmt(r.block, true), rtf_text(&r.text))); + } + s.push_str("\\par}\n"); + } + Item::Table(t) => { + let mut edges = t.cols.clone(); + edges.push(t.rect[2]); + let cellx: Vec = edges.iter().skip(1).map(|e| (e * 20.0).round() as i64).collect(); + for row in &t.rows { + s.push_str("\\trowd\\trgaph108"); + // The row's cell boundaries come first: each cell ends at the right edge of + // the last grid column it spans. + let mut column = 0; + for c in row { + column += c.span.max(1); + if let Some(x) = cellx.get(column.min(cellx.len()).saturating_sub(1)) { + s.push_str(&format!("\\cellx{x}")); + } + } + for c in row { + let mut fmt = format!("\\intbl\\fs{}", (c.size * 2.0).round() as i64); + if c.bold { + fmt.push_str("\\b"); + } + if c.italic { + fmt.push_str("\\i"); + } + fmt.push_str(&cf(c.color)); + s.push_str(&format!("{{{fmt} {}}}\\cell", rtf_text(&c.text))); + } + s.push_str("\\row\n"); + } + } + Item::Img(_) => {} + Item::PageBreak => s.push_str("\\page\n"), + } + } + s.push('}'); + s +} diff --git a/crates/export/src/layout.rs b/crates/export/src/layout.rs new file mode 100644 index 0000000000..d1d074ac61 --- /dev/null +++ b/crates/export/src/layout.rs @@ -0,0 +1,468 @@ +use std::collections::HashMap; + +/// A paragraph of a page. +#[derive(Clone, Debug, PartialEq)] +pub struct Block { + pub text: String, + /// [x0, y0, x1, y1] on the displayed page in points (y up). + pub rect: [f64; 4], + /// Font size in points. + pub size: f64, + pub bold: bool, + pub italic: bool, + /// Text fill colour as sRGB components in 0..=1 (black when unknown). + pub color: [f64; 3], + /// Counterclockwise baseline angle in user space (degrees). + pub rotation: f64, + /// Whether the source had a word space just before (`[0]`) and just after (`[1]`) `text`, + /// which `text` leaves out. A block that continues this one on its line is a run of the same + /// paragraph, and these say whether a space separates them (#4158). + pub space_around: [bool; 2], +} + +/// An image of a page. +#[derive(Clone, Debug, PartialEq)] +pub struct Image { + /// "png" or "jpg". + pub ext: &'static str, + /// Shared: a picture drawn in many places is held once. + pub bytes: std::sync::Arc<[u8]>, + pub rect: [f64; 4], + /// Width and height before rotation, in points. + pub size: [f64; 2], + /// Counterclockwise rotation in user space (degrees). + pub rotation: f64, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Page { + pub width: f64, + pub height: f64, + pub blocks: Vec, + pub images: Vec, + /// The colour the whole page is painted before its content (a page background), as sRGB + /// components in 0..=1; `None` for a page with no background of its own. + pub background: Option<[f64; 3]>, +} + +/// One cell of a detected table. `span` is how many grid columns the cell covers (a header +/// row that stretches over sub-columns). +#[derive(Clone, Debug, PartialEq)] +pub struct Cell { + pub text: String, + pub size: f64, + pub bold: bool, + pub italic: bool, + /// Text fill colour as sRGB components in 0..=1. + pub color: [f64; 3], + pub span: usize, +} + +/// A table detected from the page's text layout: rows of cells, and the left edge of each +/// grid column (user space, y up) so the writers can size the columns. +#[derive(Clone, Debug, PartialEq)] +pub struct Table { + pub rect: [f64; 4], + pub cols: Vec, + pub rows: Vec>, +} + +/// What goes into the output, in order. +pub(crate) enum Item<'a> { + /// A paragraph's runs (at least one) and its heading level. + Para(Vec>, u8), + /// Owned: tables are detected per page inside [`items`], so they cannot borrow. + Table(Table), + Img(&'a Image), + PageBreak, +} + +#[cfg(test)] +thread_local! { + /// Entries read to find a font size's or a colour's slot, for the tests that keep it constant. + pub(crate) static LOOKUP_STEPS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Counts `n` entries read by a lookup (tests only). +#[inline] +pub(crate) fn count_lookup_steps(_n: usize) { + #[cfg(test)] + LOOKUP_STEPS.with(|c| c.set(c.get().saturating_add(_n))); +} + +/// The body text size: the size most of the document's characters are set in. +pub(crate) fn body_size(pages: &[Page]) -> f64 { + // Sizes in first-seen order (which breaks ties), and where each one is in it. + let mut by: Vec<(i64, usize)> = Vec::new(); + let mut slot: HashMap = HashMap::new(); + for b in pages.iter().flat_map(|p| &p.blocks) { + let k = (b.size * 2.0).round() as i64; + count_lookup_steps(1); + let i = *slot.entry(k).or_insert_with(|| { + by.push((k, 0)); + by.len() - 1 + }); + if let Some(e) = by.get_mut(i) { + e.1 = e.1.saturating_add(b.text.len()); + } + } + by.into_iter().max_by_key(|e| e.1).map_or(11.0, |e| e.0 as f64 / 2.0) +} + +/// Heading level for a block: 1 for ≥ 1.6× the body size, 2 for ≥ 1.25×, 0 for body text. +fn level(b: &Block, body: f64) -> u8 { + let short = b.text.len() < 200; + if short && b.size >= body * 1.6 { + 1 + } else if short && (b.size >= body * 1.25 || (b.bold && b.size >= body && b.text.len() < 80 && !b.text.ends_with('.'))) { + 2 + } else { + 0 + } +} + +/// How far two cell left edges may drift (points) and still be the same grid column. +const COL_TOL: f64 = 4.0; + +/// More grid columns than this isn't a table. It is Word's limit: a .docx whose table has 64 or +/// more columns doesn't open. It also bounds a table's size: every row is materialized to all +/// its columns, so a hostile page laid out as a staircase of blocks would otherwise make +/// (blocks / 2)² cells. +pub(crate) const MAX_COLS: usize = 63; + +/// Two blocks in a row this close (as a fraction of their font size) are one line of running +/// text that changed font or colour, such as a link inside a sentence, not two table cells. +const INLINE_GAP: f64 = 0.5; + +/// How many blocks ending near a block's start are looked at to find the one it continues; a +/// page laid out so that many end at the same place stays quick. +const CONTINUATION_PROBE: usize = 64; + +/// Per block, whether it continues another on the same line: it starts where that one ends (up to +/// [`INLINE_GAP`] later, or a point earlier for rounding), so it is the same line of text running +/// on (a link inside a sentence, a superscript), not a cell of its own. +fn continuations(blocks: &[Block]) -> Vec { + continued_from(blocks).into_iter().map(|from| from.is_some()).collect() +} + +/// Per block, the block it continues on the same line (see [`continuations`]): of those it could +/// continue, the one that ends nearest to where it starts. +fn continued_from(blocks: &[Block]) -> Vec> { + let mut by_end: Vec<(f64, usize)> = blocks.iter().enumerate().map(|(i, b)| (b.rect[2], i)).collect(); + by_end.sort_by(|a, b| a.0.total_cmp(&b.0)); + blocks + .iter() + .enumerate() + .map(|(bi, b)| { + let k = by_end.partition_point(|(end, _)| *end < b.rect[0] - INLINE_GAP * b.size); + by_end + .get(k..) + .unwrap_or_default() + .iter() + .take_while(|(end, _)| *end <= b.rect[0] + 1.0) + .take(CONTINUATION_PROBE) + .filter_map(|(_, ai)| blocks.get(*ai).filter(|_| *ai != bi).map(|a| (*ai, a))) + .filter(|(_, a)| a.rect[3].min(b.rect[3]) - a.rect[1].max(b.rect[1]) > 0.5 * (a.rect[3] - a.rect[1]).min(b.rect[3] - b.rect[1])) + .min_by(|(_, a), (_, c)| (a.rect[2] - b.rect[0]).abs().total_cmp(&(c.rect[2] - b.rect[0]).abs())) + .map(|(ai, _)| ai) + }) + .collect() +} + +/// A gap between two runs wider than this (as a fraction of the font size) is a word space the +/// text left out. It is the gap the reading-block grouping uses for the same purpose. +const WORD_GAP: f64 = 0.12; + +/// One run of a paragraph: a block, and the text it contributes (its own, plus the word space +/// before the next run when the source had one there or the page shows a gap, but the text has +/// none). +pub(crate) struct Run<'a> { + pub(crate) block: &'a Block, + pub(crate) text: String, +} + +/// A page's paragraphs, each a list of runs from left to right along its line. A block that +/// continues another on the same line ([`continued_from`]: an italic case name, a link, a +/// superscript) is a run in its own style inside that block's paragraph, not a paragraph of its +/// own (#4158). Blocks marked in `skip` (table cells, a slide title) are left out and join +/// nothing. Paragraphs come in the order of their first runs in `blocks`. +pub(crate) fn paragraphs<'a>(blocks: &'a [Block], skip: &[bool]) -> Vec>> { + let skipped = |i: usize| skip.get(i).copied().unwrap_or(false); + let from = continued_from(blocks); + // Left to right. A block joins only one that starts strictly further left (in `total_cmp`'s + // order, NaN included), so that one is visited first and has its paragraph already, and the + // links cannot form a cycle. + let mut order: Vec<(f64, usize)> = blocks.iter().enumerate().filter(|(i, _)| !skipped(*i)).map(|(i, b)| (b.rect[0], i)).collect(); + order.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1))); + // Each block's paragraph, named by its first block, and each paragraph's blocks in order. + let mut head: Vec = (0..blocks.len()).collect(); + let mut members: Vec> = vec![Vec::new(); blocks.len()]; + for &(x0, bi) in &order { + let Some(b) = blocks.get(bi) else { continue }; + let joined = from + .get(bi) + .copied() + .flatten() + .filter(|ai| !skipped(*ai)) + .filter(|ai| blocks.get(*ai).is_some_and(|a| a.rect[0].total_cmp(&x0).is_lt() && (a.rotation - b.rotation).abs() <= 0.1)) + .and_then(|ai| head.get(ai).copied()); + let h = joined.unwrap_or(bi); + if let Some(slot) = head.get_mut(bi) { + *slot = h; + } + if let Some(m) = members.get_mut(h) { + m.push(bi); + } + } + members + .into_iter() + .filter(|m| !m.is_empty()) + .map(|m| { + let mut runs: Vec> = Vec::with_capacity(m.len()); + for b in m.into_iter().filter_map(|bi| blocks.get(bi)) { + if let Some(prev) = runs.last_mut() + && !prev.text.ends_with(char::is_whitespace) + && !b.text.starts_with(char::is_whitespace) + && (prev.block.space_around[1] || b.space_around[0] || b.rect[0] - prev.block.rect[2] > b.size * WORD_GAP) + { + prev.text.push(' '); + } + runs.push(Run { block: b, text: b.text.clone() }); + } + runs + }) + .collect() +} + +/// A paragraph's heading level: the lowest of its runs', so body text that runs on after a bold +/// lead-in on the same line keeps the paragraph body text. A marker set smaller than the first run +/// (a footnote number, "st" in "1st") does not count, so a heading keeps its footnote marker. +fn para_level(runs: &[Run], body: f64) -> u8 { + let Some(first) = runs.first() else { return 0 }; + let marker = |r: &Run| r.block.size < first.block.size * 0.9 && r.block.text.trim().chars().count() <= 3; + runs.iter().filter(|r| !marker(r)).map(|r| level(r.block, body)).min().unwrap_or(0) +} + +/// A cell is short: taller blocks are body text (or a multi-column layout), not table cells. +fn is_cell_like(b: &Block) -> bool { + (b.rect[3] - b.rect[1]) <= b.size * 5.0 && !b.text.trim().is_empty() +} + +/// Detect tables in a page's blocks from their text layout: columns are left edges shared by +/// several short blocks, rows are blocks whose vertical spans overlap. Returns the tables and, +/// per block, whether a table consumed it (so `items` can drop it from the paragraph flow). +pub(crate) fn tables(blocks: &[Block]) -> (Vec, Vec) { + let mut consumed = vec![false; blocks.len()]; + // Candidate columns: cluster the left edges of cell-like blocks. + let mut xs: Vec<(f64, usize)> = blocks.iter().enumerate().filter(|(_, b)| is_cell_like(b)).map(|(i, b)| (b.rect[0], i)).collect(); + xs.sort_by(|a, b| a.0.total_cmp(&b.0)); + let mut cols: Vec<(f64, Vec)> = Vec::new(); // (left edge, member block indices) + for (x, i) in xs { + match cols.last_mut() { + Some((e, m)) if (x - *e).abs() <= COL_TOL => { + *e = (*e * m.len() as f64 + x) / (m.len() + 1) as f64; + m.push(i); + } + _ => cols.push((x, vec![i])), + } + } + let cols: Vec<(f64, Vec)> = cols.into_iter().filter(|(_, m)| m.len() >= 2).collect(); + if cols.len() < 2 { + return (Vec::new(), consumed); + } + // Each block's column (an index into `cols`), so lookups stay linear on pages with many blocks. + let mut col_of: Vec> = vec![None; blocks.len()]; + for (ci, (_, members)) in cols.iter().enumerate() { + for &i in members { + if let Some(c) = col_of.get_mut(i) { + *c = Some(ci); + } + } + } + // The column whose edge is within tolerance of `x` (edges are in ascending order). + let column_at = |x: f64| -> Option { + let k = cols.partition_point(|(e, _)| *e < x - COL_TOL); + cols.get(k).filter(|(e, _)| (*e - x).abs() <= COL_TOL).map(|_| k) + }; + // Assign every cell-like block to its nearest column (within tolerance). + let mut rows: Vec> = Vec::new(); // (column index, block index), top to bottom + let mut order: Vec = (0..blocks.len()).collect(); + order.sort_by(|a, b| blocks[*b].rect[3].total_cmp(&blocks[*a].rect[3]).then(blocks[*a].rect[0].total_cmp(&blocks[*b].rect[0]))); + for &bi in &order { + let b = &blocks[bi]; + if !is_cell_like(b) { + continue; + } + let Some(ci) = column_at(b.rect[0]) else { continue }; + let joins = rows + .last() + .and_then(|row| row.first()) + .map(|(_, fi)| { + let rb = &blocks[*fi]; + let overlap = rb.rect[3].min(b.rect[3]) - rb.rect[1].max(b.rect[1]); + overlap > 0.5 * (rb.rect[3] - rb.rect[1]).min(b.rect[3] - b.rect[1]) + }) + .unwrap_or(false); + if joins { + if let Some(row) = rows.last_mut() { + row.push((ci, bi)); + } + } else { + rows.push(vec![(ci, bi)]); + } + } + // Runs of consecutive rows that have two or more distinct columns are tables. + let mut out = Vec::new(); + let flush = |run: &mut Vec>, out: &mut Vec
, consumed: &mut Vec| { + if run.len() < 2 { + run.clear(); + return; + } + let mut used: Vec = run.iter().flatten().map(|(_, i)| *i).collect(); + used.sort_unstable(); + used.dedup(); + let mut edges: Vec = used.iter().filter_map(|&i| col_of.get(i).copied().flatten()).filter_map(|ci| cols.get(ci)).map(|c| c.0).collect(); + edges.sort_by(f64::total_cmp); + edges.dedup_by(|a, b| (*a - *b).abs() <= COL_TOL); + let ncols = edges.len(); + let ok = (ncols >= 3 || (ncols == 2 && run.len() >= 3)) && ncols <= MAX_COLS; + if !ok { + run.clear(); + return; + } + let right = used.iter().map(|&i| blocks[i].rect[2]).fold(f64::MIN, f64::max) + COL_TOL; + let mut rows_out = Vec::new(); + let mut slots: Vec)>> = vec![None; ncols]; + for row in run.drain(..) { + for slot in slots.iter_mut() { + *slot = None; + } + for (_ci, bi) in row { + let b = &blocks[bi]; + let Some(slot) = edges.iter().position(|e| (*e - b.rect[0]).abs() <= COL_TOL) else { continue }; + let span = edges[slot + 1..].iter().filter(|e| **e > b.rect[0] + COL_TOL && **e < b.rect[2] - COL_TOL).count() + 1; + let cell = Cell { text: b.text.trim().to_string(), size: b.size, bold: b.bold, italic: b.italic, color: b.color, span }; + match slots.get_mut(slot) { + Some(Some((c, sources))) => { + c.text.push(' '); + c.text.push_str(&cell.text); + sources.push(bi); + } + Some(empty @ None) => *empty = Some((cell, vec![bi])), + None => continue, + } + } + let mut materialized = Vec::with_capacity(ncols); + let mut column = 0; + while column < ncols { + match slots.get_mut(column).and_then(Option::take) { + Some((mut cell, sources)) => { + // A spanning cell can skip later slots. Only consume source blocks + // when their text is emitted, leaving covered text in paragraph flow. + for bi in sources { + if let Some(taken) = consumed.get_mut(bi) { + *taken = true; + } + } + cell.span = cell.span.min(ncols - column).max(1); + column += cell.span; + materialized.push(cell); + } + None => { + column += 1; + materialized.push(Cell { text: String::new(), size: 11.0, bold: false, italic: false, color: [0.0; 3], span: 1 }); + } + } + } + while materialized.last().is_some_and(|cell| cell.text.is_empty()) && materialized.iter().any(|cell| cell.span > 1) { + materialized.pop(); + } + rows_out.push(materialized); + } + let rect = [ + edges[0], + used.iter().map(|&i| blocks[i].rect[1]).fold(f64::MAX, f64::min), + right, + used.iter().map(|&i| blocks[i].rect[3]).fold(f64::MIN, f64::max), + ]; + out.push(Table { rect, cols: edges, rows: rows_out }); + }; + // Runs of consecutive multi-column rows are tables. Rows on either side of a run that + // hold a single cell wide enough to span two columns (a header band, a totals row) join + // the table; other single-column rows (captions, following text) stay paragraphs. + let spans_run = |edges: &[f64], bi: usize| -> bool { + let b = &blocks[bi]; + edges.iter().any(|e| (*e - b.rect[0]).abs() <= COL_TOL) && edges.iter().any(|e| *e > b.rect[0] + COL_TOL && *e < b.rect[2] - COL_TOL) + }; + let run_edges = |run: &[Vec<(usize, usize)>]| -> Vec { + let mut es: Vec = + run.iter().flatten().filter_map(|(_, i)| col_of.get(*i).copied().flatten()).filter_map(|ci| cols.get(ci)).map(|c| c.0).collect(); + es.sort_by(f64::total_cmp); + es.dedup_by(|a, b| (*a - *b).abs() <= COL_TOL); + es + }; + let cont = continuations(blocks); + let qual: Vec = rows + .iter() + .map(|row| { + // A block that continues another adds no column of its own, and the cells of a table row + // sit side by side: blocks that overlap are a paragraph's lines, not cells (#2806). + let cells: Vec<(usize, usize)> = row.iter().copied().filter(|(_, bi)| !cont.get(*bi).copied().unwrap_or(false)).collect(); + let mut spans: Vec<[f64; 2]> = cells.iter().filter_map(|(_, bi)| blocks.get(*bi)).map(|b| [b.rect[0], b.rect[2]]).collect(); + spans.sort_by(|a, b| a[0].total_cmp(&b[0])); + let apart = spans.windows(2).all(|w| w[1][0] >= w[0][1] - COL_TOL); + let mut cs: Vec = cells.iter().map(|(c, _)| *c).collect(); + cs.sort_unstable(); + cs.dedup(); + apart && cs.len() >= 2 + }) + .collect(); + let mut i = 0; + while i < rows.len() { + if !qual[i] { + i += 1; + continue; + } + let start = i; + while i < rows.len() && qual[i] { + i += 1; + } + let end = i; // run = rows[start..end] + let mut pieces: Vec> = Vec::new(); + if start > 0 && rows[start - 1].len() == 1 && spans_run(&run_edges(&rows[start..end]), rows[start - 1][0].1) { + pieces.push(rows[start - 1].clone()); + } + pieces.extend(rows[start..end].iter().cloned()); + if end < rows.len() && rows[end].len() == 1 && spans_run(&run_edges(&rows[start..end]), rows[end][0].1) { + pieces.push(rows[end].clone()); + } + flush(&mut pieces, &mut out, &mut consumed); + } + (out, consumed) +} + +pub(crate) fn items(pages: &[Page]) -> Vec> { + let body = body_size(pages); + let mut out = Vec::new(); + for (i, p) in pages.iter().enumerate() { + if i > 0 { + out.push(Item::PageBreak); + } + let (tables, consumed) = tables(&p.blocks); + // Blocks and images by their top edge, top to bottom. + // A paragraph sits where its first run does: its other runs follow along the same line. + let mut parts: Vec<(f64, f64, Item)> = paragraphs(&p.blocks, &consumed) + .into_iter() + .filter_map(|runs| { + let first = runs.first()?.block; + let lvl = para_level(&runs, body); + Some((first.rect[3], first.rect[0], Item::Para(runs, lvl))) + }) + .collect(); + parts.extend(tables.into_iter().map(|t| (t.rect[3], t.rect[0], Item::Table(t)))); + parts.extend(p.images.iter().map(|im| (im.rect[3], im.rect[0], Item::Img(im)))); + parts.sort_by(|a, b| b.0.total_cmp(&a.0).then(a.1.total_cmp(&b.1))); + out.extend(parts.into_iter().map(|x| x.2)); + } + out +} diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index a9c43fb423..2b82919bbf 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -1,4 +1,4 @@ -//! pdfcraft-export — Export a PDF ▸ Word, HTML, RTF (L4). +//! pdfcraft-export — Export a PDF ▸ Word, HTML, RTF, Markdown (L4). //! //! The engine reduces each page to [`Page`]: paragraphs (text, box, size, bold/italic) and //! images (encoded bytes and box), in reading order. The writers turn that into a flowing @@ -10,1841 +10,34 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] -use std::collections::HashMap; - mod pptx; mod zip; pub use pptx::pptx; pub use zip::Zip; -/// A paragraph of a page. -#[derive(Clone, Debug, PartialEq)] -pub struct Block { - pub text: String, - /// [x0, y0, x1, y1] on the displayed page in points (y up). - pub rect: [f64; 4], - /// Font size in points. - pub size: f64, - pub bold: bool, - pub italic: bool, - /// Text fill colour as sRGB components in 0..=1 (black when unknown). - pub color: [f64; 3], - /// Counterclockwise baseline angle in user space (degrees). - pub rotation: f64, - /// Whether the source had a word space just before (`[0]`) and just after (`[1]`) `text`, - /// which `text` leaves out. A block that continues this one on its line is a run of the same - /// paragraph, and these say whether a space separates them (#4158). - pub space_around: [bool; 2], -} - -/// An image of a page. -#[derive(Clone, Debug, PartialEq)] -pub struct Image { - /// "png" or "jpg". - pub ext: &'static str, - /// Shared: a picture drawn in many places is held once. - pub bytes: std::sync::Arc<[u8]>, - pub rect: [f64; 4], - /// Width and height before rotation, in points. - pub size: [f64; 2], - /// Counterclockwise rotation in user space (degrees). - pub rotation: f64, -} - -#[derive(Clone, Debug, Default, PartialEq)] -pub struct Page { - pub width: f64, - pub height: f64, - pub blocks: Vec, - pub images: Vec, - /// The colour the whole page is painted before its content (a page background), as sRGB - /// components in 0..=1; `None` for a page with no background of its own. - pub background: Option<[f64; 3]>, -} - -/// One cell of a detected table. `span` is how many grid columns the cell covers (a header -/// row that stretches over sub-columns). -#[derive(Clone, Debug, PartialEq)] -pub struct Cell { - pub text: String, - pub size: f64, - pub bold: bool, - pub italic: bool, - /// Text fill colour as sRGB components in 0..=1. - pub color: [f64; 3], - pub span: usize, -} - -/// A table detected from the page's text layout: rows of cells, and the left edge of each -/// grid column (user space, y up) so the writers can size the columns. -#[derive(Clone, Debug, PartialEq)] -pub struct Table { - pub rect: [f64; 4], - pub cols: Vec, - pub rows: Vec>, -} - -/// What goes into the output, in order. -enum Item<'a> { - /// A paragraph's runs (at least one) and its heading level. - Para(Vec>, u8), - /// Owned: tables are detected per page inside [`items`], so they cannot borrow. - Table(Table), - Img(&'a Image), - PageBreak, -} +mod as_docx; +mod as_html; +mod as_markdown; +mod as_rtf; +mod layout; +pub use as_docx::docx; +pub(crate) use as_docx::hex; #[cfg(test)] -thread_local! { - /// Entries read to find a font size's or a colour's slot, for the tests that keep it constant. - static LOOKUP_STEPS: std::cell::Cell = const { std::cell::Cell::new(0) }; -} - -/// Counts `n` entries read by a lookup (tests only). -#[inline] -fn count_lookup_steps(_n: usize) { - #[cfg(test)] - LOOKUP_STEPS.with(|c| c.set(c.get().saturating_add(_n))); -} - -/// The body text size: the size most of the document's characters are set in. -fn body_size(pages: &[Page]) -> f64 { - // Sizes in first-seen order (which breaks ties), and where each one is in it. - let mut by: Vec<(i64, usize)> = Vec::new(); - let mut slot: HashMap = HashMap::new(); - for b in pages.iter().flat_map(|p| &p.blocks) { - let k = (b.size * 2.0).round() as i64; - count_lookup_steps(1); - let i = *slot.entry(k).or_insert_with(|| { - by.push((k, 0)); - by.len() - 1 - }); - if let Some(e) = by.get_mut(i) { - e.1 = e.1.saturating_add(b.text.len()); - } - } - by.into_iter().max_by_key(|e| e.1).map_or(11.0, |e| e.0 as f64 / 2.0) -} - -/// Heading level for a block: 1 for ≥ 1.6× the body size, 2 for ≥ 1.25×, 0 for body text. -fn level(b: &Block, body: f64) -> u8 { - let short = b.text.len() < 200; - if short && b.size >= body * 1.6 { - 1 - } else if short && (b.size >= body * 1.25 || (b.bold && b.size >= body && b.text.len() < 80 && !b.text.ends_with('.'))) { - 2 - } else { - 0 - } -} - -/// How far two cell left edges may drift (points) and still be the same grid column. -const COL_TOL: f64 = 4.0; -/// More grid columns than this isn't a table. It is Word's limit: a .docx whose table has 64 or -/// more columns doesn't open. It also bounds a table's size: every row is materialized to all -/// its columns, so a hostile page laid out as a staircase of blocks would otherwise make -/// (blocks / 2)² cells. -const MAX_COLS: usize = 63; -/// Two blocks in a row this close (as a fraction of their font size) are one line of running -/// text that changed font or colour, such as a link inside a sentence, not two table cells. -const INLINE_GAP: f64 = 0.5; -/// How many blocks ending near a block's start are looked at to find the one it continues; a -/// page laid out so that many end at the same place stays quick. -const CONTINUATION_PROBE: usize = 64; -/// Per block, whether it continues another on the same line: it starts where that one ends (up to -/// [`INLINE_GAP`] later, or a point earlier for rounding), so it is the same line of text running -/// on (a link inside a sentence, a superscript), not a cell of its own. -fn continuations(blocks: &[Block]) -> Vec { - continued_from(blocks).into_iter().map(|from| from.is_some()).collect() -} - -/// Per block, the block it continues on the same line (see [`continuations`]): of those it could -/// continue, the one that ends nearest to where it starts. -fn continued_from(blocks: &[Block]) -> Vec> { - let mut by_end: Vec<(f64, usize)> = blocks.iter().enumerate().map(|(i, b)| (b.rect[2], i)).collect(); - by_end.sort_by(|a, b| a.0.total_cmp(&b.0)); - blocks - .iter() - .enumerate() - .map(|(bi, b)| { - let k = by_end.partition_point(|(end, _)| *end < b.rect[0] - INLINE_GAP * b.size); - by_end - .get(k..) - .unwrap_or_default() - .iter() - .take_while(|(end, _)| *end <= b.rect[0] + 1.0) - .take(CONTINUATION_PROBE) - .filter_map(|(_, ai)| blocks.get(*ai).filter(|_| *ai != bi).map(|a| (*ai, a))) - .filter(|(_, a)| a.rect[3].min(b.rect[3]) - a.rect[1].max(b.rect[1]) > 0.5 * (a.rect[3] - a.rect[1]).min(b.rect[3] - b.rect[1])) - .min_by(|(_, a), (_, c)| (a.rect[2] - b.rect[0]).abs().total_cmp(&(c.rect[2] - b.rect[0]).abs())) - .map(|(ai, _)| ai) - }) - .collect() -} - -/// A gap between two runs wider than this (as a fraction of the font size) is a word space the -/// text left out. It is the gap the reading-block grouping uses for the same purpose. -const WORD_GAP: f64 = 0.12; - -/// One run of a paragraph: a block, and the text it contributes (its own, plus the word space -/// before the next run when the source had one there or the page shows a gap, but the text has -/// none). -pub(crate) struct Run<'a> { - pub(crate) block: &'a Block, - pub(crate) text: String, -} - -/// A page's paragraphs, each a list of runs from left to right along its line. A block that -/// continues another on the same line ([`continued_from`]: an italic case name, a link, a -/// superscript) is a run in its own style inside that block's paragraph, not a paragraph of its -/// own (#4158). Blocks marked in `skip` (table cells, a slide title) are left out and join -/// nothing. Paragraphs come in the order of their first runs in `blocks`. -pub(crate) fn paragraphs<'a>(blocks: &'a [Block], skip: &[bool]) -> Vec>> { - let skipped = |i: usize| skip.get(i).copied().unwrap_or(false); - let from = continued_from(blocks); - // Left to right. A block joins only one that starts strictly further left (in `total_cmp`'s - // order, NaN included), so that one is visited first and has its paragraph already, and the - // links cannot form a cycle. - let mut order: Vec<(f64, usize)> = blocks.iter().enumerate().filter(|(i, _)| !skipped(*i)).map(|(i, b)| (b.rect[0], i)).collect(); - order.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1))); - // Each block's paragraph, named by its first block, and each paragraph's blocks in order. - let mut head: Vec = (0..blocks.len()).collect(); - let mut members: Vec> = vec![Vec::new(); blocks.len()]; - for &(x0, bi) in &order { - let Some(b) = blocks.get(bi) else { continue }; - let joined = from - .get(bi) - .copied() - .flatten() - .filter(|ai| !skipped(*ai)) - .filter(|ai| blocks.get(*ai).is_some_and(|a| a.rect[0].total_cmp(&x0).is_lt() && (a.rotation - b.rotation).abs() <= 0.1)) - .and_then(|ai| head.get(ai).copied()); - let h = joined.unwrap_or(bi); - if let Some(slot) = head.get_mut(bi) { - *slot = h; - } - if let Some(m) = members.get_mut(h) { - m.push(bi); - } - } - members - .into_iter() - .filter(|m| !m.is_empty()) - .map(|m| { - let mut runs: Vec> = Vec::with_capacity(m.len()); - for b in m.into_iter().filter_map(|bi| blocks.get(bi)) { - if let Some(prev) = runs.last_mut() - && !prev.text.ends_with(char::is_whitespace) - && !b.text.starts_with(char::is_whitespace) - && (prev.block.space_around[1] || b.space_around[0] || b.rect[0] - prev.block.rect[2] > b.size * WORD_GAP) - { - prev.text.push(' '); - } - runs.push(Run { block: b, text: b.text.clone() }); - } - runs - }) - .collect() -} - -/// A paragraph's heading level: the lowest of its runs', so body text that runs on after a bold -/// lead-in on the same line keeps the paragraph body text. A marker set smaller than the first run -/// (a footnote number, "st" in "1st") does not count, so a heading keeps its footnote marker. -fn para_level(runs: &[Run], body: f64) -> u8 { - let Some(first) = runs.first() else { return 0 }; - let marker = |r: &Run| r.block.size < first.block.size * 0.9 && r.block.text.trim().chars().count() <= 3; - runs.iter().filter(|r| !marker(r)).map(|r| level(r.block, body)).min().unwrap_or(0) -} - -/// A cell is short: taller blocks are body text (or a multi-column layout), not table cells. -fn is_cell_like(b: &Block) -> bool { - (b.rect[3] - b.rect[1]) <= b.size * 5.0 && !b.text.trim().is_empty() -} - -/// Detect tables in a page's blocks from their text layout: columns are left edges shared by -/// several short blocks, rows are blocks whose vertical spans overlap. Returns the tables and, -/// per block, whether a table consumed it (so `items` can drop it from the paragraph flow). -pub(crate) fn tables(blocks: &[Block]) -> (Vec
, Vec) { - let mut consumed = vec![false; blocks.len()]; - // Candidate columns: cluster the left edges of cell-like blocks. - let mut xs: Vec<(f64, usize)> = blocks.iter().enumerate().filter(|(_, b)| is_cell_like(b)).map(|(i, b)| (b.rect[0], i)).collect(); - xs.sort_by(|a, b| a.0.total_cmp(&b.0)); - let mut cols: Vec<(f64, Vec)> = Vec::new(); // (left edge, member block indices) - for (x, i) in xs { - match cols.last_mut() { - Some((e, m)) if (x - *e).abs() <= COL_TOL => { - *e = (*e * m.len() as f64 + x) / (m.len() + 1) as f64; - m.push(i); - } - _ => cols.push((x, vec![i])), - } - } - let cols: Vec<(f64, Vec)> = cols.into_iter().filter(|(_, m)| m.len() >= 2).collect(); - if cols.len() < 2 { - return (Vec::new(), consumed); - } - // Each block's column (an index into `cols`), so lookups stay linear on pages with many blocks. - let mut col_of: Vec> = vec![None; blocks.len()]; - for (ci, (_, members)) in cols.iter().enumerate() { - for &i in members { - if let Some(c) = col_of.get_mut(i) { - *c = Some(ci); - } - } - } - // The column whose edge is within tolerance of `x` (edges are in ascending order). - let column_at = |x: f64| -> Option { - let k = cols.partition_point(|(e, _)| *e < x - COL_TOL); - cols.get(k).filter(|(e, _)| (*e - x).abs() <= COL_TOL).map(|_| k) - }; - // Assign every cell-like block to its nearest column (within tolerance). - let mut rows: Vec> = Vec::new(); // (column index, block index), top to bottom - let mut order: Vec = (0..blocks.len()).collect(); - order.sort_by(|a, b| blocks[*b].rect[3].total_cmp(&blocks[*a].rect[3]).then(blocks[*a].rect[0].total_cmp(&blocks[*b].rect[0]))); - for &bi in &order { - let b = &blocks[bi]; - if !is_cell_like(b) { - continue; - } - let Some(ci) = column_at(b.rect[0]) else { continue }; - let joins = rows - .last() - .and_then(|row| row.first()) - .map(|(_, fi)| { - let rb = &blocks[*fi]; - let overlap = rb.rect[3].min(b.rect[3]) - rb.rect[1].max(b.rect[1]); - overlap > 0.5 * (rb.rect[3] - rb.rect[1]).min(b.rect[3] - b.rect[1]) - }) - .unwrap_or(false); - if joins { - if let Some(row) = rows.last_mut() { - row.push((ci, bi)); - } - } else { - rows.push(vec![(ci, bi)]); - } - } - // Runs of consecutive rows that have two or more distinct columns are tables. - let mut out = Vec::new(); - let flush = |run: &mut Vec>, out: &mut Vec
, consumed: &mut Vec| { - if run.len() < 2 { - run.clear(); - return; - } - let mut used: Vec = run.iter().flatten().map(|(_, i)| *i).collect(); - used.sort_unstable(); - used.dedup(); - let mut edges: Vec = used.iter().filter_map(|&i| col_of.get(i).copied().flatten()).filter_map(|ci| cols.get(ci)).map(|c| c.0).collect(); - edges.sort_by(f64::total_cmp); - edges.dedup_by(|a, b| (*a - *b).abs() <= COL_TOL); - let ncols = edges.len(); - let ok = (ncols >= 3 || (ncols == 2 && run.len() >= 3)) && ncols <= MAX_COLS; - if !ok { - run.clear(); - return; - } - let right = used.iter().map(|&i| blocks[i].rect[2]).fold(f64::MIN, f64::max) + COL_TOL; - let mut rows_out = Vec::new(); - let mut slots: Vec)>> = vec![None; ncols]; - for row in run.drain(..) { - for slot in slots.iter_mut() { - *slot = None; - } - for (_ci, bi) in row { - let b = &blocks[bi]; - let Some(slot) = edges.iter().position(|e| (*e - b.rect[0]).abs() <= COL_TOL) else { continue }; - let span = edges[slot + 1..].iter().filter(|e| **e > b.rect[0] + COL_TOL && **e < b.rect[2] - COL_TOL).count() + 1; - let cell = Cell { text: b.text.trim().to_string(), size: b.size, bold: b.bold, italic: b.italic, color: b.color, span }; - match slots.get_mut(slot) { - Some(Some((c, sources))) => { - c.text.push(' '); - c.text.push_str(&cell.text); - sources.push(bi); - } - Some(empty @ None) => *empty = Some((cell, vec![bi])), - None => continue, - } - } - let mut materialized = Vec::with_capacity(ncols); - let mut column = 0; - while column < ncols { - match slots.get_mut(column).and_then(Option::take) { - Some((mut cell, sources)) => { - // A spanning cell can skip later slots. Only consume source blocks - // when their text is emitted, leaving covered text in paragraph flow. - for bi in sources { - if let Some(taken) = consumed.get_mut(bi) { - *taken = true; - } - } - cell.span = cell.span.min(ncols - column).max(1); - column += cell.span; - materialized.push(cell); - } - None => { - column += 1; - materialized.push(Cell { text: String::new(), size: 11.0, bold: false, italic: false, color: [0.0; 3], span: 1 }); - } - } - } - while materialized.last().is_some_and(|cell| cell.text.is_empty()) && materialized.iter().any(|cell| cell.span > 1) { - materialized.pop(); - } - rows_out.push(materialized); - } - let rect = [ - edges[0], - used.iter().map(|&i| blocks[i].rect[1]).fold(f64::MAX, f64::min), - right, - used.iter().map(|&i| blocks[i].rect[3]).fold(f64::MIN, f64::max), - ]; - out.push(Table { rect, cols: edges, rows: rows_out }); - }; - // Runs of consecutive multi-column rows are tables. Rows on either side of a run that - // hold a single cell wide enough to span two columns (a header band, a totals row) join - // the table; other single-column rows (captions, following text) stay paragraphs. - let spans_run = |edges: &[f64], bi: usize| -> bool { - let b = &blocks[bi]; - edges.iter().any(|e| (*e - b.rect[0]).abs() <= COL_TOL) && edges.iter().any(|e| *e > b.rect[0] + COL_TOL && *e < b.rect[2] - COL_TOL) - }; - let run_edges = |run: &[Vec<(usize, usize)>]| -> Vec { - let mut es: Vec = - run.iter().flatten().filter_map(|(_, i)| col_of.get(*i).copied().flatten()).filter_map(|ci| cols.get(ci)).map(|c| c.0).collect(); - es.sort_by(f64::total_cmp); - es.dedup_by(|a, b| (*a - *b).abs() <= COL_TOL); - es - }; - let cont = continuations(blocks); - let qual: Vec = rows - .iter() - .map(|row| { - // A block that continues another adds no column of its own, and the cells of a table row - // sit side by side: blocks that overlap are a paragraph's lines, not cells (#2806). - let cells: Vec<(usize, usize)> = row.iter().copied().filter(|(_, bi)| !cont.get(*bi).copied().unwrap_or(false)).collect(); - let mut spans: Vec<[f64; 2]> = cells.iter().filter_map(|(_, bi)| blocks.get(*bi)).map(|b| [b.rect[0], b.rect[2]]).collect(); - spans.sort_by(|a, b| a[0].total_cmp(&b[0])); - let apart = spans.windows(2).all(|w| w[1][0] >= w[0][1] - COL_TOL); - let mut cs: Vec = cells.iter().map(|(c, _)| *c).collect(); - cs.sort_unstable(); - cs.dedup(); - apart && cs.len() >= 2 - }) - .collect(); - let mut i = 0; - while i < rows.len() { - if !qual[i] { - i += 1; - continue; - } - let start = i; - while i < rows.len() && qual[i] { - i += 1; - } - let end = i; // run = rows[start..end] - let mut pieces: Vec> = Vec::new(); - if start > 0 && rows[start - 1].len() == 1 && spans_run(&run_edges(&rows[start..end]), rows[start - 1][0].1) { - pieces.push(rows[start - 1].clone()); - } - pieces.extend(rows[start..end].iter().cloned()); - if end < rows.len() && rows[end].len() == 1 && spans_run(&run_edges(&rows[start..end]), rows[end][0].1) { - pieces.push(rows[end].clone()); - } - flush(&mut pieces, &mut out, &mut consumed); - } - (out, consumed) -} - -fn items(pages: &[Page]) -> Vec> { - let body = body_size(pages); - let mut out = Vec::new(); - for (i, p) in pages.iter().enumerate() { - if i > 0 { - out.push(Item::PageBreak); - } - let (tables, consumed) = tables(&p.blocks); - // Blocks and images by their top edge, top to bottom. - // A paragraph sits where its first run does: its other runs follow along the same line. - let mut parts: Vec<(f64, f64, Item)> = paragraphs(&p.blocks, &consumed) - .into_iter() - .filter_map(|runs| { - let first = runs.first()?.block; - let lvl = para_level(&runs, body); - Some((first.rect[3], first.rect[0], Item::Para(runs, lvl))) - }) - .collect(); - parts.extend(tables.into_iter().map(|t| (t.rect[3], t.rect[0], Item::Table(t)))); - parts.extend(p.images.iter().map(|im| (im.rect[3], im.rect[0], Item::Img(im)))); - parts.sort_by(|a, b| b.0.total_cmp(&a.0).then(a.1.total_cmp(&b.1))); - out.extend(parts.into_iter().map(|x| x.2)); - } - out -} - -/// Whether a character may appear in the output. XML 1.0 forbids the C0 controls other than tab, -/// line feed and carriage return, and U+FFFE/U+FFFF. Text extracted from PDFs often holds them -/// (fonts without a usable `/ToUnicode`), and Word refuses a whole document over one (#72). -fn allowed(c: char) -> bool { - matches!(c, '\t' | '\n' | '\r') || !(c.is_ascii_control() || c == '\u{FFFE}' || c == '\u{FFFF}') -} - -pub(crate) fn esc(s: &str) -> String { - let mut o = String::with_capacity(s.len()); - for c in s.chars().filter(|c| allowed(*c)) { - match c { - '&' => o.push_str("&"), - '<' => o.push_str("<"), - '>' => o.push_str(">"), - '"' => o.push_str("""), - c => o.push(c), - } - } - o -} - -fn base64(data: &[u8]) -> String { - const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - let mut s = String::with_capacity(data.len().div_ceil(3) * 4); - for c in data.chunks(3) { - let n = (c[0] as u32) << 16 | (*c.get(1).unwrap_or(&0) as u32) << 8 | *c.get(2).unwrap_or(&0) as u32; - s.push(A[(n >> 18) as usize & 63] as char); - s.push(A[(n >> 12) as usize & 63] as char); - s.push(if c.len() > 1 { A[(n >> 6) as usize & 63] as char } else { '=' }); - s.push(if c.len() > 2 { A[n as usize & 63] as char } else { '=' }); - } - s -} - -/// One HTML file: headings and paragraphs, tables as real `
` elements, images inline, a -/// rule between pages. -pub fn html(pages: &[Page], title: &str) -> String { - let mut s = format!( - "\n\n\n\n{}\n\n\n\n", - esc(title) - ); - for it in items(pages) { - match it { - Item::Para(runs, lvl) => { - let Some(first) = runs.first().map(|r| r.block) else { continue }; - let mut t = String::new(); - for r in &runs { - let mut run = esc(&r.text); - if r.block.italic { - run = format!("{run}"); - } - if r.block.bold && lvl == 0 { - run = format!("{run}"); - } - // The paragraph has its first run's colour; a run in another colour says so. - let colour = rgb8(r.block.color); - if colour != rgb8(first.color) { - let [red, green, blue] = colour; - run = format!("{run}"); - } - t.push_str(&run); - } - let style = hex(first.color).map_or(String::new(), |h| format!(" style=\"color:#{h}\"")); - match lvl { - 0 => s.push_str(&format!("{t}

\n")), - l => s.push_str(&format!("{t}\n")), - } - } - Item::Table(t) => { - s.push_str("
\n"); - for row in &t.rows { - s.push_str(""); - for c in row { - let mut body = esc(&c.text); - if c.italic { - body = format!("{body}"); - } - if c.bold { - body = format!("{body}"); - } - let span = if c.span > 1 { format!(" colspan=\"{}\"", c.span) } else { String::new() }; - let style = hex(c.color).map_or(String::new(), |h| format!(" style=\"color:#{h}\"")); - s.push_str(&format!("{body}")); - } - s.push_str("\n"); - } - s.push_str("
\n"); - } - Item::Img(im) => { - let mime = if im.ext == "jpg" { "image/jpeg" } else { "image/png" }; - s.push_str(&format!("

\"\"

\n", base64(&im.bytes))); - } - Item::PageBreak => s.push_str("
\n"), - } - } - s.push_str("\n\n"); - s -} - -/// A colour's 8-bit sRGB components (NaN and out-of-range values clamp; NaN reads as 0). -pub(crate) fn rgb8(c: [f64; 3]) -> [u8; 3] { - // The value is clamped to 0..=255 before the cast, so it cannot truncate. - c.map(|v| if v.is_finite() { (v.clamp(0.0, 1.0) * 255.0).round() as u8 } else { 0 }) -} - -/// The colour as `RRGGBB`, or `None` for black (the default every format already uses). -pub(crate) fn hex(c: [f64; 3]) -> Option { - let [r, g, b] = rgb8(c); - (r | g | b != 0).then(|| format!("{r:02X}{g:02X}{b:02X}")) -} - -/// A formatted text run (shared by paragraphs and table cells). -fn run_xml(text: &str, size: f64, bold: bool, italic: bool, color: [f64; 3]) -> String { - let mut rpr = String::new(); - if bold { - rpr.push_str(""); - } - if italic { - rpr.push_str(""); - } - if let Some(h) = hex(color) { - rpr.push_str(&format!("")); - } - let half_points = if size.is_finite() { (size * 2.0).round().clamp(2.0, 3276.0) as i64 } else { 24 }; - rpr.push_str(&format!("")); - format!("{rpr}{}", esc(text)) -} - -/// One Word table: explicit absent borders (alignment alone cannot prove PDF rules), a grid sized -/// from the detected column edges, `gridSpan` for spanning cells, empty cells for holes. -fn docx_table(t: &Table) -> String { - let mut edges = t.cols.clone(); - edges.push(t.rect[2]); - let widths: Vec = edges.windows(2).map(|w| ((w[1] - w[0]).max(20.0) * 20.0).round().clamp(60.0, 31680.0) as i64).collect(); - let mut s = String::from(""); - for edge in ["top", "left", "bottom", "right", "insideH", "insideV"] { - s.push_str(&format!("")); - } - s.push_str(""); - for w in &widths { - s.push_str(&format!("")); - } - s.push_str(""); - for row in &t.rows { - s.push_str(""); - let mut column = 0usize; - for c in row { - let span = if c.span > 1 { format!("", c.span) } else { String::new() }; - let w: i64 = widths.iter().skip(column).take(c.span).sum(); - s.push_str(&format!( - "{span}{}", - run_xml(&c.text, c.size, c.bold, c.italic, c.color) - )); - column = column.saturating_add(c.span); - } - // Pad the grid so every row covers all columns (Word rejects short rows). - for _ in column..t.cols.len() { - s.push_str(""); - } - s.push_str(""); - } - s.push_str(""); - s -} - -/// Pages with rotated text, or text that actually overlaps an image or an oversized -/// (watermark-like) text block, cannot be put in paragraph flow without changing their layout. -/// Keep those pages as editable, absolutely positioned text boxes. Every other page, including -/// one with a logo or a big title that sits clear of the text, flows as paragraphs and tables. -fn positioned(page: &Page) -> bool { - if page.blocks.iter().any(|b| b.rotation.abs() > 0.1) { - return true; - } - let body = body_size(std::slice::from_ref(page)).max(1.0); - let overlays = page - .images - .iter() - .map(|im| (im.rect, None)) - .chain(page.blocks.iter().enumerate().filter(|(_, b)| b.size >= 2.0 * body).map(|(i, b)| (b.rect, Some(i)))); - // Bound the pairwise test on hostile pages (many overlays times many blocks): past the - // budget, keep the default flowing layout. - let mut budget: usize = 1_000_000; - for (r, own) in overlays { - for (i, b) in page.blocks.iter().enumerate() { - if budget == 0 { - return false; - } - budget -= 1; - if Some(i) != own && overlaps(r, b.rect) { - return true; - } - } - } - false -} - -/// Whether two [x0, y0, x1, y1] boxes share more than a hairline (0.5 pt each way); boxes that -/// only touch, or hostile non-finite coordinates, do not count. -fn overlaps(a: [f64; 4], b: [f64; 4]) -> bool { - let w = a[2].min(b[2]) - a[0].max(b[0]); - let h = a[3].min(b[3]) - a[1].max(b[1]); - w.is_finite() && h.is_finite() && w > 0.5 && h > 0.5 -} - -/// Bound every value written into a shape's style, including hostile PDF coordinates. -fn layout_number(n: f64) -> f64 { - if n.is_finite() { n.clamp(-1584.0, 1584.0) } else { 0.0 } -} - -/// Most images a Word export embeds, and most image bytes (the zip writer has 32-bit sizes and -/// a 16-bit entry count; a hostile PDF must not be able to overflow them). -const MAX_MEDIA: usize = 1000; -const MAX_MEDIA_BYTES: usize = 512 << 20; - -/// Whether `im` can still be embedded: it has data, and the package stays within its limits. -fn room_for(media: &[(String, &Image)], im: &Image) -> bool { - !im.bytes.is_empty() - && media.len() < MAX_MEDIA - && media.iter().map(|(_, m)| m.bytes.len()).fold(im.bytes.len(), usize::saturating_add) <= MAX_MEDIA_BYTES -} - -/// Points as EMU (12700 per point); non-finite values are 0. -fn emu(pt: f64) -> i64 { - if pt.is_finite() { (pt * 12700.0).round() as i64 } else { 0 } -} - -/// The `a:graphic` of one picture (DrawingML), rotated by `rotation` degrees when given. -fn picture_xml(n: usize, name: &str, (cx, cy): (i64, i64), rotation: Option) -> String { - let rot = rotation.map_or(String::new(), |r| format!(" rot=\"{}\"", (r * 60000.0).round() as i64)); - format!( - "\ -\ -\ -\ -\ -" - ) -} - -fn positioned_page<'a>(page: &'a Page, media: &mut Vec<(String, &'a Image)>, shape_id: &mut usize) -> String { - // One 1 pt paragraph anchors the whole page. Shapes never reserve body space, so even a - // page-sized watermark cannot push the invoice footer onto another page. - let mut s = String::from(""); - let body = body_size(std::slice::from_ref(page)).max(1.0); - for b in &page.blocks { - *shape_id = shape_id.saturating_add(1); - let rotation = layout_number(-b.rotation).rem_euclid(360.0); - let (sin, cos) = rotation.to_radians().sin_cos(); - let (bw, bh) = (layout_number(b.rect[2] - b.rect[0]).max(1.0), layout_number(b.rect[3] - b.rect[1]).max(1.0)); - let h = if b.rotation.abs() > 0.1 { layout_number(b.size).max(1.0) } else { bh }; - // Recover the unrotated width from the axis-aligned box. Use the larger axis so - // quarter turns and 45-degree watermarks do not divide by (nearly) zero. - let w = if b.rotation.abs() <= 0.1 { - bw - } else if cos.abs() >= sin.abs() { - (bw - h * sin.abs()) / cos.abs() - } else { - (bh - h * cos.abs()) / sin.abs() - }; - let w = layout_number(w).max(1.0); - let (x, y) = (layout_number(b.rect[0] + (bw - w) / 2.0), layout_number(page.height - b.rect[3] + (bh - h) / 2.0)); - let z = if b.size >= 2.0 * body { -2 } else { 1 }; - let style = format!( - "position:absolute;margin-left:{x:.3}pt;margin-top:{y:.3}pt;width:{w:.3}pt;height:{h:.3}pt;rotation:{rotation:.3};z-index:{z};mso-position-horizontal-relative:page;mso-position-vertical-relative:page" - ); - s.push_str(&format!( - "{}", - shape_id, run_xml(&b.text, b.size, b.bold, b.italic, b.color) - )); - } - for im in &page.images { - if !room_for(media, im) { - continue; - } - *shape_id = shape_id.saturating_add(1); - let n = media.len().saturating_add(1); - let (w, h) = (layout_number(im.size[0]).max(1.0), layout_number(im.size[1]).max(1.0)); - let (x, y) = (layout_number((im.rect[0] + im.rect[2] - w) / 2.0), layout_number(page.height - (im.rect[1] + im.rect[3] + h) / 2.0)); - let rotation = layout_number(-im.rotation).rem_euclid(360.0); - let name = format!("image{n}.{}", im.ext); - let pic = picture_xml(n, &name, (emu(w), emu(h)), Some(rotation)); - // A DrawingML anchor, the way Word itself stores a picture placed behind text: a VML - // rectangle with an image does not show the picture. - s.push_str(&format!( - "\ -{}{}\ -", - n.saturating_add(1), - emu(x), - emu(y), - emu(w), - emu(h), - )); - s.push_str(&pic); - s.push_str(""); - media.push((name, im)); - } - s.push_str(""); - s -} - -fn word_page_size(page: &Page) -> (i64, i64) { - let twips = |pt: f64| if pt.is_finite() { (pt * 20.0).round().clamp(144.0, 31680.0) as i64 } else { 12240 }; - (twips(page.width), twips(page.height)) -} - -fn section(page: &Page) -> String { - let (pw, ph) = word_page_size(page); - let (mx, my) = ((pw / 8).min(1440), (ph / 8).min(1440)); - format!( - "", - my / 2, - my / 2 - ) -} - -/// The page colour of a Word document: Word has one background for the whole document, so it -/// is written only when every page has the same, non-white background (#2992). -fn word_background(pages: &[Page]) -> Option { - let first = rgb8(pages.first()?.background?); - let same = pages.iter().all(|p| p.background.map(rgb8) == Some(first)); - (same && first != [255, 255, 255]).then(|| format!("{:02X}{:02X}{:02X}", first[0], first[1], first[2])) -} - -/// A Word document (.docx, Office Open XML). Ordinary pages flow as paragraphs and tables; -/// pages with rotated text, or text that overlaps artwork, use floating shapes to retain their layout. -pub fn docx(pages: &[Page], title: &str) -> Vec { - let mut body = String::new(); - let mut media: Vec<(String, &Image)> = Vec::new(); - // Word needs a paragraph between adjacent tables and after the last one in the body. - let mut after_table = false; - let mut shape_id = 0usize; - for (page_index, page) in pages.iter().enumerate() { - let (pw, _) = word_page_size(page); - let text_w = (pw - 2 * (pw / 8).min(1440)) as f64 / 20.0; - if positioned(page) { - body.push_str(&positioned_page(page, &mut media, &mut shape_id)); - after_table = false; - } else { - for it in items(std::slice::from_ref(page)) { - match &it { - Item::Para(runs, lvl) => { - let style = match lvl { - 1 => "", - 2 => "", - _ => "", - }; - body.push_str(""); - body.push_str(style); - for r in runs { - body.push_str(&run_xml(&r.text, r.block.size, r.block.bold, r.block.italic, r.block.color)); - } - body.push_str(""); - } - Item::Table(t) => { - if after_table { - body.push_str(""); - } - body.push_str(&docx_table(t)); - } - Item::Img(im) => { - if room_for(&media, im) { - let n = media.len() + 1; - let name = format!("image{n}.{}", im.ext); - // Size on the page, at most the text width. - let (w, h) = ((im.rect[2] - im.rect[0]).max(1.0), (im.rect[3] - im.rect[1]).max(1.0)); - let k = (text_w / w).min(1.0); - let (cx, cy) = (emu(w * k), emu(h * k)); - body.push_str(&format!("")); - body.push_str(&picture_xml(n, &name, (cx, cy), None)); - body.push_str(""); - media.push((name, im)); - } - } - Item::PageBreak => body.push_str(""), - } - after_table = matches!(it, Item::Table(_)); - } - } - // Section breaks carry this source page's size and create exactly one next page. - // The last section must be a direct child of the body (OOXML 17.6.17). - if page_index + 1 < pages.len() { - body.push_str(&format!( - "{}", - section(page) - )); - } else { - if after_table || page.blocks.is_empty() && page.images.is_empty() { - body.push_str(""); - } - body.push_str(§ion(page)); - } - after_table = false; - } - // Word shows a document background only when its settings say so (`settings.xml` below). - let page_colour = word_background(pages); - let background = page_colour.as_deref().map(|c| format!("")).unwrap_or_default(); - let doc = format!( - "\n\ -{background}{body}\ -" - ); - let mut types = String::from( - "\n\ -\ -\ -\ -\ -", - ); - if page_colour.is_some() { - types.push_str( - "", - ); - } - types.push_str(""); - let rels = "\n\ -\ -"; - let mut doc_rels = String::from( - "\n\ -", - ); - for (i, (name, _)) in media.iter().enumerate() { - doc_rels.push_str(&format!( - "", - i + 1 - )); - } - if page_colour.is_some() { - doc_rels.push_str(""); - } - doc_rels.push_str(""); - let styles = "\n\ -\ -\ -\ -"; - let core = format!( - "\n{}", - esc(title) - ); - let mut z = Zip::default(); - z.add("[Content_Types].xml", types.as_bytes(), true); - z.add("_rels/.rels", rels.as_bytes(), true); - z.add("docProps/core.xml", core.as_bytes(), true); - z.add("word/document.xml", doc.as_bytes(), true); - z.add("word/styles.xml", styles.as_bytes(), true); - z.add("word/_rels/document.xml.rels", doc_rels.as_bytes(), true); - if page_colour.is_some() { - z.add("word/settings.xml", b"\n", true); - } - for (name, im) in &media { - z.add(&format!("word/media/{name}"), &im.bytes, false); - } - z.finish() -} - -fn rtf_text(s: &str) -> String { - let mut o = String::new(); - for c in s.chars().filter(|c| allowed(*c)) { - match c { - '\\' | '{' | '}' => { - o.push('\\'); - o.push(c); - } - c if (c as u32) < 128 => o.push(c), - c => { - // \uN with a ? fallback; UTF-16 units as signed 16-bit numbers. - let mut buf = [0u16; 2]; - for u in c.encode_utf16(&mut buf) { - o.push_str(&format!("\\u{}?", *u as i16)); - } - } - } - } - o -} - -/// Rich Text Format: paragraphs with their sizes and bold/italic, real table rows, page breaks -/// between pages (images are left out). -pub fn rtf(pages: &[Page]) -> String { - let items = items(pages); - // The colour table: entry 0 is the default (auto) colour, then each non-black colour once. - let mut colors: Vec<[u8; 3]> = Vec::new(); - let mut slot: HashMap<[u8; 3], usize> = HashMap::new(); - let mut note = |c: [f64; 3]| { - let c = rgb8(c); - count_lookup_steps(1); - if c != [0, 0, 0] && !slot.contains_key(&c) { - slot.insert(c, colors.len()); - colors.push(c); - } - }; - for it in &items { - match it { - Item::Para(runs, _) => runs.iter().for_each(|r| note(r.block.color)), - Item::Table(t) => t.rows.iter().flatten().for_each(|c| note(c.color)), - Item::Img(_) | Item::PageBreak => {} - } - } - // `\cfN` for a colour (nothing for black, which keeps the default). - let cf = |c: [f64; 3]| { - let c = rgb8(c); - count_lookup_steps(1); - slot.get(&c).map_or(String::new(), |i| format!("\\cf{}", i + 1)) - }; - let mut s = String::from("{\\rtf1\\ansi\\deff0{\\fonttbl{\\f0 Helvetica;}}\n"); - if !colors.is_empty() { - s.push_str("{\\colortbl;"); - for [r, g, b] in &colors { - s.push_str(&format!("\\red{r}\\green{g}\\blue{b};")); - } - s.push_str("}\n"); - } - for it in items { - match it { - Item::Para(runs, _) => { - // The first run's formatting is the paragraph's; each later run is a group that - // turns on or off what differs from it. - let fmt = |b: &Block, later: bool| { - let mut fmt = format!("\\fs{}", (b.size * 2.0).round() as i64); - if b.bold { - fmt.push_str("\\b"); - } else if later { - fmt.push_str("\\b0"); - } - if b.italic { - fmt.push_str("\\i"); - } else if later { - fmt.push_str("\\i0"); - } - let colour = cf(b.color); - fmt.push_str(if colour.is_empty() && later { "\\cf0" } else { &colour }); - fmt - }; - let mut runs = runs.iter(); - let Some(first) = runs.next() else { continue }; - s.push_str(&format!("{{\\pard{} {}", fmt(first.block, false), rtf_text(&first.text))); - for r in runs { - s.push_str(&format!("{{{} {}}}", fmt(r.block, true), rtf_text(&r.text))); - } - s.push_str("\\par}\n"); - } - Item::Table(t) => { - let mut edges = t.cols.clone(); - edges.push(t.rect[2]); - let cellx: Vec = edges.iter().skip(1).map(|e| (e * 20.0).round() as i64).collect(); - for row in &t.rows { - s.push_str("\\trowd\\trgaph108"); - // The row's cell boundaries come first: each cell ends at the right edge of - // the last grid column it spans. - let mut column = 0; - for c in row { - column += c.span.max(1); - if let Some(x) = cellx.get(column.min(cellx.len()).saturating_sub(1)) { - s.push_str(&format!("\\cellx{x}")); - } - } - for c in row { - let mut fmt = format!("\\intbl\\fs{}", (c.size * 2.0).round() as i64); - if c.bold { - fmt.push_str("\\b"); - } - if c.italic { - fmt.push_str("\\i"); - } - fmt.push_str(&cf(c.color)); - s.push_str(&format!("{{{fmt} {}}}\\cell", rtf_text(&c.text))); - } - s.push_str("\\row\n"); - } - } - Item::Img(_) => {} - Item::PageBreak => s.push_str("\\page\n"), - } - } - s.push('}'); - s -} - +use as_docx::{MAX_MEDIA, docx_table}; +pub(crate) use as_html::esc; #[cfg(test)] -mod tests { - use super::*; - - /// A page of `n` one-word blocks, each in its own size and colour. - fn varied_page(n: usize) -> Page { - let blocks = (0..n) - .map(|i| Block { - text: "word".into(), - rect: [72.0, i as f64, 100.0, i as f64 + 1.0], - size: 4.0 + i as f64 * 0.5, - bold: false, - italic: false, - color: [(i % 256) as f64 / 255.0, (i / 256 % 256) as f64 / 255.0, 0.5], - rotation: 0.0, - space_around: [false; 2], - }) - .collect(); - Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None } - } - - fn lookup_steps(f: impl FnOnce()) -> usize { - LOOKUP_STEPS.with(|c| c.set(0)); - f(); - LOOKUP_STEPS.with(|c| c.get()) - } - - #[test] - fn many_distinct_sizes_are_counted_in_constant_steps_each() { - let pages = [varied_page(3000)]; - let steps = lookup_steps(|| { - body_size(&pages); - }); - assert!(steps <= 2 * 3000, "{steps} steps"); - } - - #[test] - fn many_distinct_colours_take_constant_steps_each() { - let pages = [varied_page(3000)]; - let mut out = String::new(); - let steps = lookup_steps(|| out = rtf(&pages)); - assert!(steps <= 4 * 3000, "{steps} steps"); - assert!(out.contains("\\cf3000 ") || out.contains("\\cf3000\\"), "the last colour has the last slot"); - } - - fn page() -> Page { - let b = |t: &str, y: f64, size: f64, bold: bool| Block { - text: t.into(), - rect: [72.0, y, 500.0, y + size], - size, - bold, - italic: false, - color: [0.0; 3], - rotation: 0.0, - space_around: [false; 2], - }; - Page { - width: 612.0, - height: 792.0, - blocks: vec![ - b("Body text after the picture, which is long enough to be the main text size of the page.", 400.0, 11.0, false), - b("Annual Report", 700.0, 24.0, true), - b("Overview", 650.0, 14.0, true), - b("Some text & more of it so that eleven points is the body size here.", 620.0, 11.0, false), - ], - images: vec![Image { - ext: "png", - bytes: b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0\x02\0\0\0\x03".as_slice().into(), - rect: [72.0, 450.0, 272.0, 600.0], - size: [200.0, 150.0], - rotation: 0.0, - }], - background: None, - } - } - - #[test] - fn html_has_headings_paragraphs_and_images_in_order() { - let h = html(&[page(), page()], "Report"); - let order: Vec = ["

Annual Report

", "

Overview

", "

Some <body> text &", "Body text after"] - .iter() - .map(|x| h.find(x).unwrap_or_else(|| panic!("{x} missing in {h}"))) - .collect(); - assert!(order.windows(2).all(|w| w[0] < w[1]), "{order:?}"); - assert_eq!(h.matches("


").count(), 1); - assert_eq!(base64(b"Man"), "TWFu"); - assert_eq!(base64(b"Ma"), "TWE="); - } - - #[test] - fn docx_is_a_word_package() { - let d = docx(&[page()], "Report"); - assert!(d.starts_with(b"PK")); - let names: Vec<&str> = - ["[Content_Types].xml", "word/document.xml", "word/styles.xml", "word/_rels/document.xml.rels", "word/media/image1.png", "_rels/.rels"] - .to_vec(); - for n in names { - assert!(d.windows(n.len()).any(|w| w == n.as_bytes()), "{n}"); - } - } - - #[test] - fn word_does_not_invent_borders_for_aligned_invoice_fields() { - let xml = part(&docx(&[table_page()], "Invoice"), "word/document.xml"); - assert!(xml.contains("")); - assert!(!xml.contains("w:val=\"single\""), "text alignment is not evidence of a border: {xml}"); - assert!(xml.contains("")); - } - - #[test] - fn word_table_cell_widths_follow_the_columns_they_span() { - let cell = |text: &str, span| Cell { text: text.into(), size: 11.0, bold: false, italic: false, color: [0.0; 3], span }; - let table = Table { - rect: [0.0, 0.0, 300.0, 100.0], - cols: vec![0.0, 50.0, 150.0], - rows: vec![ - vec![cell("First", 1), cell("Second", 1), cell("Third", 1)], - vec![cell("First", 1), cell("Last two", 2)], - vec![cell("First two", 2), cell("Third", 1)], - vec![cell("All", 3)], - vec![cell("First", 1)], - ], - }; - let xml = docx_table(&table); - assert!(xml.contains(""), "{xml}"); - let widths: Vec> = - xml.split("").skip(1).map(|row| row.split(""), "the picture stays inline: {xml}"); - assert!(xml.contains(""), "the title stays a heading: {xml}"); - let (title, body) = (xml.find(">Annual Report"), xml.find(">Body text after the picture")); - assert!(title.is_some() && body.is_some() && title < body, "reading order kept: {xml}"); - // The same page with the picture moved over the body text floats. - let mut over = page(); - over.images[0].rect = [72.0, 380.0, 272.0, 530.0]; - let xml = part(&docx(&[over], "Report"), "word/document.xml"); - assert!(xml.contains("position:absolute"), "an image over text floats: {xml}"); - } - - #[test] - fn word_overlap_does_not_consume_invoice_body_space() { - let mut p = table_page(); - p.blocks.push(Block { - text: "DRAFT".into(), - rect: [100.0, 200.0, 500.0, 700.0], - size: 100.0, - bold: false, - italic: false, - color: [0.8; 3], - rotation: 0.0, - space_around: [false; 2], - }); - p.images = page().images; - let xml = part(&docx(&[p], "Invoice"), "word/document.xml"); - assert!(xml.contains("position:absolute"), "overlapping text must float: {xml}"); - assert!(!xml.contains(""), "images must not displace invoice text"); - assert!(!xml.contains(""), "the source text boxes must keep their positions"); - assert_eq!(xml.matches(">DRAFT").count(), 1); - assert_eq!(xml.matches("w:type=\"page\"").count(), 0); - assert!(xml.contains("margin-left:72.000pt;margin-top:160.000pt"), "Meter field keeps its source position: {xml}"); - let watermark = xml.split(">DRAFT").next().unwrap().rsplit(""), "{xml}"); - } - - #[test] - fn word_rotated_pages_keep_separate_page_sizes_and_unique_shapes() { - let mut p = table_page(); - p.blocks[0].rotation = 45.0; - let mut second = p.clone(); - second.width = 400.0; - second.height = 500.0; - let xml = part(&docx(&[p, second], "Two invoices"), "word/document.xml"); - assert!(xml.contains("rotation:315.000"), "PDF y-up becomes Word y-down"); - assert_eq!(xml.matches("").count(), 2); - assert!(xml.contains("")); - assert_eq!(xml.matches("id=\"Text1\"").count(), 1); - assert_eq!(xml.matches(">Meter").count(), 2); - } - - /// #2992: pages that share a background colour get it as the Word document's page colour - /// (with the setting that makes Word show it); differing or white backgrounds don't. - #[test] - fn docx_keeps_a_shared_page_background() { - let teal = Page { background: Some([0.0, 0.5, 0.5]), ..page() }; - let zip = docx(&[teal.clone(), teal.clone()], "Teal"); - let xml = part(&zip, "word/document.xml"); - assert!(xml.contains(""), "{xml}"); - assert!(part(&zip, "word/settings.xml").contains("")); - assert!(part(&zip, "[Content_Types].xml").contains("/word/settings.xml")); - assert!(part(&zip, "word/_rels/document.xml.rels").contains("Target=\"settings.xml\"")); - let other = Page { background: Some([1.0, 0.0, 0.0]), ..page() }; - for (pages, why) in [ - (vec![teal.clone(), other], "different colours"), - (vec![teal.clone(), page()], "a page without one"), - (vec![Page { background: Some([1.0; 3]), ..page() }], "white"), - (vec![page()], "none"), - ] { - let zip = docx(&pages, "Plain"); - assert!(!part(&zip, "word/document.xml").contains(" String { - let at = |i: usize, n: usize| zip[i..i + n].iter().rev().fold(0usize, |v, b| v << 8 | *b as usize); - let mut i = 0; - while zip[i..].starts_with(b"PK\x03\x04") { - let (size, name_len) = (at(i + 18, 4), at(i + 26, 2)); - let data = i + 30 + name_len; - if &zip[i + 30..data] == name.as_bytes() { - let mut s = String::new(); - std::io::Read::read_to_string(&mut flate2::read::DeflateDecoder::new(&zip[data..data + size]), &mut s).unwrap(); - return s; - } - i = data + size; - } - panic!("{name} missing") - } - - #[test] - fn word_opens_text_with_control_characters_and_long_pages() { - // #72: text extracted without a usable /ToUnicode holds C0 controls, which XML forbids; - // Word then refused the whole file. A till receipt is also taller than Word's 22 in. - let mut p = page(); - p.height = 2400.0; - p.blocks.push(Block { - text: "Total\u{0}\u{3}\u{c} 4.50\u{FFFF}\tGBP".into(), - rect: [72.0, 100.0, 300.0, 110.0], - size: f64::NAN, - bold: false, - italic: false, - color: [0.0; 3], - rotation: 0.0, - space_around: [false; 2], - }); - let xml = part(&docx(&[p.clone()], "Receipt\u{1}"), "word/document.xml"); - assert!(xml.contains(">Total 4.50\tGBP"), "{xml}"); - assert!(!xml.chars().any(|c| !allowed(c))); - assert!(xml.contains(""), "clamped to 22 in: {xml}"); - assert!(xml.contains(""), "a non-finite size falls back to 12 pt"); - assert!(rtf(&[p]).contains("Total 4.50\tGBP")); - // A narrow page keeps room for text: margins shrink. - let narrow = Page { width: 100.0, height: 200.0, ..page() }; - let xml = part(&docx(&[narrow], "Narrow"), "word/document.xml"); - assert!(xml.contains(""), "{xml}"); - assert!(xml.contains(""), "out-of-range values clamp: {xml}"); - // Black text keeps Word's default colour. - assert_eq!(xml.matches("Annual Report"), "{h}"); - assert!(h.contains("

"), "{h}"); - let r = rtf(&[p.clone()]); - assert!(r.contains("{\\colortbl;\\red13\\green59\\blue46;\\red0\\green255\\blue0;}"), "{r}"); - assert!(r.contains("\\cf1 Annual Report"), "{r}"); - let ppt = pptx(&[p], "Colour"); - let pxml = part(&ppt, "ppt/slides/slide1.xml"); - assert!(pxml.contains(""), "{pxml}"); - assert!(pxml.contains(""), "out-of-range values clamp: {pxml}"); - // A table cell keeps its colour too. - let mut t = table_page(); - t.blocks[0].color = [1.0, 0.0, 0.0]; - let xml = part(&docx(&[t.clone()], "Grid"), "word/document.xml"); - assert!(xml.contains(""), "{xml}"); - assert!(html(&[t], "Grid").contains("style=\"color:#FF0000\"")); - } - - #[test] - fn rtf_escapes_and_sizes() { - let mut p = page(); - p.blocks.push(Block { - text: "Café {x}".into(), - rect: [72.0, 100.0, 200.0, 110.0], - size: 10.0, - bold: false, - italic: true, - color: [0.0; 3], - rotation: 0.0, - space_around: [false; 2], - }); - let r = rtf(&[p]); - assert!(r.starts_with("{\\rtf1") && r.ends_with('}')); - assert!(r.contains("\\fs48\\b Annual Report")); - assert!(r.contains("\\fs20\\i Caf\\u233? \\{x\\}"), "{r}"); - } - - /// A 3-column table with a spanning header row and a hole in the last row. - fn table_page() -> Page { - let cell = |t: &str, x: f64, y: f64, w: f64| Block { - text: t.into(), - rect: [x, y, x + w, y + 12.0], - size: 11.0, - bold: false, - italic: false, - color: [0.0; 3], - rotation: 0.0, - space_around: [false; 2], - }; - Page { - width: 612.0, - height: 792.0, - blocks: vec![ - cell("Meter", 72.0, 620.0, 120.0), - cell("Unit", 232.0, 620.0, 80.0), - cell("Reading", 392.0, 620.0, 100.0), - cell("A1", 72.0, 590.0, 120.0), - cell("kWh", 232.0, 590.0, 80.0), - cell("37414.00", 392.0, 590.0, 100.0), - cell("A2", 72.0, 560.0, 120.0), - cell("kW", 232.0, 560.0, 80.0), - // A2's row is missing its third cell (an empty cell in the grid). - cell("Totals", 72.0, 530.0, 240.0), - Block { - text: "Notes follow the table and are long enough to be the body text size of the page here.".into(), - rect: [72.0, 460.0, 500.0, 471.0], - size: 11.0, - bold: false, - italic: false, - color: [0.0; 3], - rotation: 0.0, - space_around: [false; 2], - }, - ], - images: Vec::new(), - background: None, - } - } - - #[test] - fn grid_text_becomes_a_real_table_in_every_format() { - let p = table_page(); - let h = html(std::slice::from_ref(&p), "Bill"); - assert!(h.contains(""), "{h}"); - assert!(h.contains(""), "spanning cell: {h}"); - assert_eq!(h.matches("").count(), 4, "{h}"); - assert!(h.contains(""), "the hole is an empty cell: {h}"); - assert!(h.contains("

Notes follow"), "the paragraph after the table stays: {h}"); - - let d = docx(std::slice::from_ref(&p), "Bill"); - assert!(d.starts_with(b"PK")); - assert!(d.windows(b"word/document.xml".len()).any(|w| w == b"word/document.xml")); - let (tables, _) = tables(&p.blocks); - assert_eq!(tables.len(), 1); - let table_xml = docx_table(&tables[0]); - assert!(table_xml.contains("")); - assert!(table_xml.contains(""), "{table_xml}"); - assert_eq!(table_xml.matches("").count(), 4); - - let r = rtf(std::slice::from_ref(&p)); - assert!(r.contains("\\trowd"), "{r}"); - assert_eq!(r.matches("\\row").count(), 4); - // Boundaries precede the cells; "Totals" spans two columns, so it ends at the third edge. - assert!(r.contains("\\trowd\\trgaph108\\cellx4640\\cellx7840\\cellx9920{"), "{r}"); - assert!(r.contains("\\trowd\\trgaph108\\cellx7840{\\intbl\\fs22 Totals}\\cell\\row"), "{r}"); - - let pt = pptx(std::slice::from_ref(&p), "Bill"); - assert!(pt.starts_with(b"PK")); - assert!(pt.windows(b"ppt/slides/slide1.xml".len()).any(|w| w == b"ppt/slides/slide1.xml")); - let slide_xml = part(&pt, "ppt/slides/slide1.xml"); - assert!(slide_xml.contains("")); - assert!(slide_xml.contains("gridSpan=\"2\"")); - assert_eq!(slide_xml.matches(" = (0..n) - .map(|i| { - let (row, col) = (i / 2, i / 2 + i % 2); - let (x, y) = (10.0 + col as f64 * 20.0, 10_000.0 - row as f64 * 14.0); - Block { - text: "x".into(), - rect: [x, y, x + 8.0, y + 12.0], - size: 11.0, - bold: false, - italic: false, - color: [0.0; 3], - rotation: 0.0, - space_around: [false; 2], - } - }) - .collect(); - let started = std::time::Instant::now(); - let (tables, _) = tables(&blocks); - assert!(tables.iter().all(|t| t.cols.len() <= MAX_COLS), "{} columns", tables.iter().map(|t| t.cols.len()).max().unwrap_or(0)); - assert!(started.elapsed() < std::time::Duration::from_secs(5), "took {:?}", started.elapsed()); - } - - #[test] - fn word_opens_tables_up_to_its_column_limit() { - // Rows of short numbers on a regular grid, n columns wide. - let grid = |n: usize| Page { - width: 1500.0, - height: 792.0, - blocks: (0..3) - .flat_map(|row| { - (0..n).map(move |col| { - let (x, y) = (20.0 + col as f64 * 22.0, 700.0 - row as f64 * 14.0); - Block { - text: (col + 1).to_string(), - rect: [x, y, x + 8.0, y + 8.0], - size: 8.0, - bold: false, - italic: false, - color: [0.0; 3], - rotation: 0.0, - space_around: [false; 2], - } - }) - }) - .collect(), - images: Vec::new(), - background: None, - }; - let grid_cols = |p: &Page| part(&docx(std::slice::from_ref(p), "Grid"), "word/document.xml").matches(" Block { - Block { - text: t.into(), - rect: [x0, y0, x1, y1], - size: 12.0, - bold: false, - italic: false, - color: [0.0; 3], - rotation: 0.0, - space_around: [false; 2], - } - } - - /// #2806: a sentence whose link is set in another colour or font runs on as separate blocks - /// on one line. They are not table columns, even when several such lines share left edges. - #[test] - fn a_link_inside_a_sentence_is_not_a_table_column() { - let blue = |t: &str, x0: f64, y0: f64, x1: f64, y1: f64| Block { color: [0.02, 0.388, 0.757], ..block(t, x0, y0, x1, y1) }; - let p = Page { - width: 612.0, - height: 792.0, - blocks: vec![ - block("6.3. 2. All content shown at", 121.7, 710.3, 272.0, 724.9), - blue("http", 273.5, 710.3, 294.1, 724.9), - blue("s", 294.1, 709.8, 298.8, 725.8), - blue("://women", 298.8, 710.3, 348.0, 724.9), - blue("g", 348.1, 709.8, 353.7, 725.8), - blue("randprix.fide.com", 353.7, 710.3, 443.2, 724.9), - block("shall be reviewed and approved by the Press Officer.", 164.3, 622.5, 533.1, 724.9), - block("6.3. 3. The Organiser shall warrant that", 121.7, 601.8, 345.1, 616.5), - blue("https://womengrandprix.fide.com", 350.2, 601.3, 519.9, 617.4), - block("is managed in a professional way and furthermore that it is capable of", 164.3, 557.9, 533.0, 616.5), - block("6. 3. 4. The Press Officer shall report to the FIDE Head of PR.", 121.7, 516.6, 530.3, 551.9), - block("conducted during a WGP tournament.", 164.3, 502.0, 352.5, 516.6), - ], - images: Vec::new(), - background: None, - }; - let h = html(std::slice::from_ref(&p), "Regulations"); - assert!(!h.contains("

Totals
"), "running text became a table: {h}"); - // The link is a run of its sentence, in its own colour (#4158). - assert!(h.contains("

6.3. 2. All content shown at http"), "{h}"); - for b in &p.blocks { - assert!(h.contains(&b.text), "{} was lost: {h}", b.text); - } - } - - /// #2806: lines that each run on into a link starting at the same place are sentences, not a - /// two-column table, even three in a row. - #[test] - fn sentences_that_run_on_into_links_are_not_a_table() { - let blue = |t: &str, x0: f64, y0: f64, x1: f64, y1: f64| Block { color: [0.02, 0.388, 0.757], ..block(t, x0, y0, x1, y1) }; - let mut blocks = Vec::new(); - for (n, lead) in ["Rules are published at", "Pairings are posted at", "Results are listed at"].iter().enumerate() { - let y = 700.0 - 16.0 * n as f64; - blocks.push(block(lead, 72.0, y, 211.0, y + 12.0)); - blocks.push(blue("https://example.org/event", 212.5, y, 360.0, y + 12.0)); - } - let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None }; - let h = html(std::slice::from_ref(&p), "Event"); - assert!(!h.contains("

"), "sentences became a table: {h}"); - assert_eq!(h.matches("https://example.org/event").count(), 3, "{h}"); - } - - /// A continuation can drift into a grid column covered by the preceding cell's span. - /// It must remain available as paragraph text if that slot never becomes an output cell. - #[test] - fn table_spans_do_not_consume_text_they_do_not_emit() { - let mut blocks = vec![ - Block { size: 12.0, ..block("ABCDEFGHIJK", 72.1, 697.0, 151.3, 710.8) }, - Block { size: 12.0, color: [0.0, 0.0, 1.0], ..block("LOST", 150.4, 697.0, 179.2, 710.8) }, - Block { size: 12.0, ..block("R0", 250.0, 697.0, 264.4, 710.8) }, - ]; - for row in 1..=4 { - let y = 697.0 - f64::from(row) * 50.0; - for (text, left, right) in [(format!("L{row}"), 72.1, 86.5), (format!("M{row}"), 146.4, 160.8), (format!("R{row}"), 250.0, 264.4)] { - blocks.push(Block { size: 12.0, ..block(&text, left, y, right, y + 13.8) }); - } - } - let p = Page { width: 612.0, height: 792.0, blocks, ..Page::default() }; - let (tables, consumed) = tables(&p.blocks); - assert_eq!(tables.len(), 1); - assert_eq!(tables[0].rows[0][0].span, 2); - assert!(!consumed[1], "a covered slot did not emit its text"); - assert!(consumed.iter().enumerate().all(|(index, used)| index == 1 || *used)); - let outputs = [ - html(std::slice::from_ref(&p), "Continuations"), - part(&docx(std::slice::from_ref(&p), "Continuations"), "word/document.xml"), - rtf(std::slice::from_ref(&p)), - part(&pptx(std::slice::from_ref(&p), "Continuations"), "ppt/slides/slide1.xml"), - ]; - for output in outputs { - for b in &p.blocks { - assert_eq!(output.matches(&b.text).count(), 1, "{} must survive exactly once", b.text); - } - } - } - - /// #2806: a superscript right after a number ("1" "st") continues its cell; the prize table - /// around it stays a table. - #[test] - fn a_superscript_beside_a_number_keeps_its_table() { - let mut blocks = vec![block("PLACE", 100.0, 700.0, 140.0, 712.0), block("PRIZE (euros)", 300.0, 700.0, 380.0, 712.0)]; - for (n, (row, sup)) in [("1", "st"), ("2", "nd"), ("3", "rd"), ("4", "th")].iter().enumerate() { - let y = 680.0 - 20.0 * n as f64; - blocks.push(block(row, 100.0, y, 106.0, y + 12.0)); - blocks.push(Block { size: 8.0, ..block(sup, 106.0, y + 5.0, 113.0, y + 13.0) }); - blocks.push(block(&format!("{},000", 30 - 5 * n), 300.0, y, 340.0, y + 12.0)); - } - let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None }; - let h = html(std::slice::from_ref(&p), "Prizes"); - assert!(h.contains("
"), "the prize table was lost: {h}"); - assert!(h.contains("30,000") && h.contains("15,000"), "{h}"); - let (tables, consumed) = tables(&p.blocks); - assert!(consumed.iter().all(|used| *used), "ordinary table continuations stay in their cells"); - let cells: Vec<&str> = tables.iter().flat_map(|t| &t.rows).flatten().map(|c| c.text.as_str()).collect(); - assert!(["st", "nd", "rd", "th"].iter().all(|suffix| cells.contains(suffix))); - } - - #[test] - fn two_column_body_text_is_not_mistaken_for_a_table() { - let para = |t: &str, x: f64, y: f64| Block { - text: t.into(), - rect: [x, y, x + 200.0, y + 90.0], // nine lines tall: body text, not a cell - size: 11.0, - bold: false, - italic: false, - color: [0.0; 3], - rotation: 0.0, - space_around: [false; 2], - }; - let p = Page { - width: 612.0, - height: 792.0, - blocks: vec![ - para("Left column text of the page, long enough to be a flowing paragraph.", 72.0, 500.0), - para("Right column text of the page, long enough to be a flowing paragraph.", 320.0, 500.0), - para("Second left block, still a tall flowing paragraph rather than a cell.", 72.0, 400.0), - para("Second right block, still a tall flowing paragraph rather than a cell.", 320.0, 400.0), - ], - images: Vec::new(), - background: None, - }; - let h = html(&[p], "Paper"); - assert!(!h.contains("
"), "tall two-column text must stay paragraphs: {h}"); - } - - /// #1936: every picture Word is told to draw (`r:embed`) names a relationship whose target is a - /// file in the package, with a content type; JPEG pictures too, inline or placed. - #[test] - fn word_pictures_resolve_to_media_in_the_package() { - let jpeg = Image { ext: "jpg", bytes: [0xFF, 0xD8, 0xFF, 0xD9].as_slice().into(), ..page().images[0].clone() }; - for placed in [false, true] { - let mut p = page(); - p.images = vec![jpeg.clone()]; - if placed { - p.images[0].rect = [72.0, 640.0, 272.0, 790.0]; - p.blocks[0].rect = [72.0, 700.0, 300.0, 760.0]; - } - let zip = docx(&[p], "T"); - let xml = part(&zip, "word/document.xml"); - assert_eq!(xml.contains(" Vec> { - let body = xml.split("").map(|s| s.split_once("").map_or(s, |(_, after)| after)).collect::(); - body.split("") - .skip(1) - .map(|p| { - let p = p.split_once("").map_or(p, |(inside, _)| inside); - p.split("") - .skip(1) - .filter_map(|r| { - let text = r.split_once("")?.1.split_once("")?.0; - Some((text.to_string(), r.contains(""))) - }) - .collect::>() - }) - .filter(|runs| !runs.is_empty()) - .collect() - } - - fn runs(r: &[(&str, bool)]) -> Vec<(String, bool)> { - r.iter().map(|(t, i)| (t.to_string(), *i)).collect() - } - - /// "Before *Chevron* after." on one line: the roman text, the italic case name and the roman - /// text after it, the way reading blocks split a sentence at its style changes (#4158). The - /// italic face stands a little taller than the roman one around it. - fn italic_sentence() -> Vec { - vec![ - block("Before", 72.0, 700.0, 110.0, 712.0), - Block { italic: true, ..block("Chevron", 113.0, 700.0, 158.0, 712.6) }, - block("after.", 161.0, 700.0, 190.0, 712.0), - ] - } - - /// #4158: a run in another style inside a sentence stays in that sentence's paragraph, as a - /// run in its own style, in every Office format. - #[test] - fn an_italic_word_stays_in_its_sentence() { - let mut blocks = italic_sentence(); - blocks.push(block("The next line is a paragraph of its own.", 72.0, 684.0, 300.0, 696.0)); - let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None }; - let xml = part(&docx(std::slice::from_ref(&p), "Opinion"), "word/document.xml"); - assert_eq!( - word_paragraphs(&xml), - vec![runs(&[("Before ", false), ("Chevron ", true), ("after.", false)]), runs(&[("The next line is a paragraph of its own.", false)])], - "{xml}" - ); - let h = html(std::slice::from_ref(&p), "Opinion"); - assert!(h.contains("

Before Chevron after.

"), "{h}"); - let r = rtf(std::slice::from_ref(&p)); - assert!(r.contains("{\\pard\\fs24 Before {\\fs24\\b0\\i\\cf0 Chevron }{\\fs24\\b0\\i0\\cf0 after.}\\par}"), "{r}"); - // The longest line is the slide's title; the sentence is one paragraph of the body. - let pxml = part(&pptx(std::slice::from_ref(&p), "Opinion"), "ppt/slides/slide1.xml"); - assert!( - pxml.contains("Before Chevron after."), - "{pxml}" - ); - } - - /// #4158: the runs keep their order along the line whatever order the blocks come in, and the - /// paragraph keeps its place on the page, though the italic run stands taller than the first. - #[test] - fn a_sentence_keeps_its_reading_order() { - let mut blocks = vec![block("Then the next line.", 72.0, 684.0, 200.0, 696.0)]; - blocks.extend(italic_sentence().into_iter().rev()); - let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None }; - let xml = part(&docx(std::slice::from_ref(&p), "Opinion"), "word/document.xml"); - assert_eq!( - word_paragraphs(&xml), - vec![runs(&[("Before ", false), ("Chevron ", true), ("after.", false)]), runs(&[("Then the next line.", false)])], - "{xml}" - ); - } - - /// The text of a block leaves out the spaces at its ends, and the box of a run often takes in - /// its trailing space, so runs can touch. The source's own space still separates them; runs - /// that touch with no space between them (a superscript) stay joined. - #[test] - fn runs_keep_the_spaces_the_source_had_between_them() { - let blocks = vec![ - Block { space_around: [false, true], ..block("Before", 72.0, 700.0, 113.0, 712.0) }, - Block { italic: true, ..block("Chevron", 113.0, 700.0, 158.0, 712.0) }, - Block { space_around: [true, false], ..block("after.", 158.0, 700.0, 190.0, 712.0) }, - block("E = mc", 72.0, 660.0, 110.0, 672.0), - Block { size: 8.0, ..block("2", 110.0, 665.0, 115.0, 673.0) }, - ]; - let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None }; - let xml = part(&docx(std::slice::from_ref(&p), "Spaces"), "word/document.xml"); - assert_eq!( - word_paragraphs(&xml), - vec![runs(&[("Before ", false), ("Chevron ", true), ("after.", false)]), runs(&[("E = mc", false), ("2", false)])], - "{xml}" - ); - } - /// Text turned another way (a label up the margin) is not a run of the line it touches. - #[test] - fn turned_text_is_not_a_run_of_the_line_beside_it() { - let blocks = vec![block("Before", 72.0, 700.0, 110.0, 712.0), Block { rotation: 90.0, ..block("SIDE", 111.0, 690.0, 123.0, 720.0) }]; - let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None }; - let h = html(std::slice::from_ref(&p), "Turned"); - assert!(h.contains("

Before

") && h.contains("

SIDE

"), "{h}"); - } - /// A block that starts where the line above ends is the next line, not a run of it. - #[test] - fn a_block_on_the_next_line_starts_a_paragraph() { - let blocks = vec![ - block("The first line ends here", 72.0, 700.0, 190.0, 712.0), - Block { italic: true, ..block("and the next starts there.", 190.5, 690.0, 320.0, 702.0) }, - ]; - let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None }; - let xml = part(&docx(std::slice::from_ref(&p), "Lines"), "word/document.xml"); - assert_eq!(word_paragraphs(&xml), vec![runs(&[("The first line ends here", false)]), runs(&[("and the next starts there.", true)])], "{xml}"); - } - - /// A superscript beside a table cell stays in the table: table detection consumes it before - /// paragraphs are formed, and a sentence after the table still keeps its italic run. - #[test] - fn table_cells_are_not_runs_of_a_paragraph() { - let mut blocks = vec![block("PLACE", 100.0, 700.0, 140.0, 712.0), block("PRIZE (euros)", 300.0, 700.0, 380.0, 712.0)]; - for (n, (row, sup)) in [("1", "st"), ("2", "nd"), ("3", "rd")].iter().enumerate() { - let y = 680.0 - 20.0 * n as f64; - blocks.push(block(row, 100.0, y, 106.0, y + 12.0)); - blocks.push(Block { size: 8.0, ..block(sup, 106.0, y + 5.0, 113.0, y + 13.0) }); - blocks.push(block(&format!("{},000", 30 - 5 * n), 300.0, y, 340.0, y + 12.0)); - } - blocks.extend(italic_sentence().into_iter().map(|b| Block { rect: [b.rect[0], b.rect[1] - 200.0, b.rect[2], b.rect[3] - 200.0], ..b })); - let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None }; - let xml = part(&docx(std::slice::from_ref(&p), "Prizes"), "word/document.xml"); - assert_eq!(xml.matches("").count(), 1, "{xml}"); - for cell in ["st", "nd", "rd", "30,000"] { - assert_eq!(xml.matches(&format!("preserve\">{cell}")).count(), 1, "{cell}: {xml}"); - } - // The cells are all in the table: the only flowing paragraph is the sentence. - assert_eq!(word_paragraphs(&xml), vec![runs(&[("Before ", false), ("Chevron ", true), ("after.", false)])], "{xml}"); - } +use as_html::{allowed, base64}; +pub use as_html::{html, html_simple}; +pub use as_markdown::markdown; +#[cfg(test)] +use as_markdown::{emphasis, paragraph}; +pub use as_rtf::rtf; +pub use layout::{Block, Cell, Image, Page, Table}; +#[cfg(test)] +use layout::{LOOKUP_STEPS, MAX_COLS, body_size}; +pub(crate) use layout::{Run, paragraphs, tables}; - /// A bold lead-in followed by body text on its line is body text, not a heading; a heading - /// with a smaller footnote marker after it stays a heading. - #[test] - fn a_paragraph_is_a_heading_only_when_its_text_is() { - let blocks = vec![ - Block { size: 24.0, ..block("Annual Report", 72.0, 740.0, 250.0, 764.0) }, - Block { size: 14.0, ..block("1", 251.0, 752.0, 258.0, 766.0) }, - Block { bold: true, ..block("Summary", 72.0, 700.0, 125.0, 712.0) }, - block("the year closed with more members than it opened with, and fewer debts.", 128.0, 700.0, 540.0, 712.0), - ]; - let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None }; - let xml = part(&docx(std::slice::from_ref(&p), "Report"), "word/document.xml"); - assert_eq!(xml.matches("").count(), 1, "{xml}"); - assert!(!xml.contains("Heading2"), "the lead-in made its paragraph a heading: {xml}"); - assert_eq!(word_paragraphs(&xml).len(), 2, "{xml}"); - } -} +#[cfg(test)] +mod tests; diff --git a/crates/export/src/pptx.rs b/crates/export/src/pptx.rs index 2edc0e25c9..49765000d3 100644 --- a/crates/export/src/pptx.rs +++ b/crates/export/src/pptx.rs @@ -358,398 +358,4 @@ xmlns:a=\"http://schemas.openxmlformats.org/drawingml/2006/main\" type=\"blank\" } #[cfg(test)] -mod tests { - use super::*; - use crate::Block; - use crate::tests::part; - - fn sample_page() -> Page { - Page { - width: 720.0, - height: 540.0, - blocks: vec![ - Block { - text: "Q3 Business Update".into(), - rect: [72.0, 460.0, 500.0, 500.0], - size: 28.0, - bold: true, - italic: false, - color: [0.1, 0.2, 0.3], - rotation: 0.0, - space_around: [false; 2], - }, - Block { - text: "Revenue grew 25% year over year across all regions.".into(), - rect: [72.0, 380.0, 500.0, 410.0], - size: 14.0, - bold: false, - italic: false, - color: [0.0; 3], - rotation: 0.0, - space_around: [false; 2], - }, - Block { - text: "Operational expenses remained flat.".into(), - rect: [72.0, 340.0, 500.0, 370.0], - size: 14.0, - bold: false, - italic: true, - color: [0.0; 3], - rotation: 0.0, - space_around: [false; 2], - }, - ], - images: vec![Image { - ext: "png", - bytes: b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0\x02\0\0\0\x03".as_slice().into(), - rect: [72.0, 150.0, 300.0, 300.0], - size: [228.0, 150.0], - rotation: 0.0, - }], - background: None, - } - } - - #[test] - pub fn pptx_is_a_presentationml_package() { - let p = sample_page(); - let bytes = pptx(&[p], "Executive Deck"); - assert!(bytes.starts_with(b"PK")); - for part_name in [ - "[Content_Types].xml", - "_rels/.rels", - "docProps/core.xml", - "ppt/presentation.xml", - "ppt/_rels/presentation.xml.rels", - "ppt/slideMasters/slideMaster1.xml", - "ppt/slideMasters/_rels/slideMaster1.xml.rels", - "ppt/slideLayouts/slideLayout1.xml", - "ppt/slideLayouts/_rels/slideLayout1.xml.rels", - "ppt/theme/theme1.xml", - "ppt/slides/slide1.xml", - "ppt/slides/_rels/slide1.xml.rels", - "ppt/media/image1.png", - ] { - assert!(bytes.windows(part_name.len()).any(|w| w == part_name.as_bytes()), "missing {part_name}"); - } - } - - #[test] - pub fn pptx_slide_has_title_body_and_images_in_order() { - let p = sample_page(); - let bytes = pptx(&[p], "Deck"); - let slide_xml = part(&bytes, "ppt/slides/slide1.xml"); - assert!(slide_xml.contains("name=\"Title 2\"")); - assert!(slide_xml.contains("txBox=\"1\"")); - assert!(slide_xml.contains("Q3 Business Update")); - assert!(slide_xml.contains("name=\"TextBox 3\"")); - assert!(slide_xml.contains("Revenue grew 25%")); - assert!(slide_xml.contains("")); - assert!(slide_xml.contains("")); - assert!(!slide_xml.contains("")); - assert!(slide_xml.contains("i=\"1\"")); - } - - /// Every entry of the package in order: (name, uncompressed bytes), read from the local - /// headers the way an unzipper walks them, so a broken structure fails here. - fn entries(zip: &[u8]) -> Vec<(String, Vec)> { - let at = |i: usize, n: usize| zip[i..i + n].iter().rev().fold(0usize, |v, b| v << 8 | *b as usize); - let mut out = Vec::new(); - let mut i = 0; - while zip[i..].starts_with(b"PK\x03\x04") { - let (method, size, raw, name_len, extra) = (at(i + 8, 2), at(i + 18, 4), at(i + 22, 4), at(i + 26, 2), at(i + 28, 2)); - let data = i + 30 + name_len + extra; - let name = String::from_utf8(zip[i + 30..i + 30 + name_len].to_vec()).expect("UTF-8 name"); - let body = &zip[data..data + size]; - let bytes = match method { - 0 => body.to_vec(), - 8 => { - let mut v = Vec::new(); - std::io::Read::read_to_end(&mut flate2::read::DeflateDecoder::new(body), &mut v).expect("inflates"); - v - } - m => panic!("{name}: unexpected method {m}"), - }; - assert_eq!(bytes.len(), raw, "{name}: uncompressed size"); - out.push((name, bytes)); - i = data + size; - } - assert!(zip[i..].starts_with(b"PK\x01\x02"), "the central directory follows the entries"); - out - } - - /// Checks that every start tag is closed by the matching end tag (text is escaped, so a - /// `<` always starts markup). Catches a typo such as `…`. - fn assert_tags_balance(name: &str, xml: &str) { - let mut stack: Vec<&str> = Vec::new(); - for chunk in xml.split('<').skip(1) { - let tag = chunk.split('>').next().unwrap_or_default(); - if tag.starts_with('?') { - continue; - } - if let Some(close) = tag.strip_prefix('/') { - assert_eq!(stack.pop(), Some(close.trim()), "{name}: closes the wrong element"); - } else if !tag.ends_with('/') { - stack.push(tag.split_whitespace().next().unwrap_or_default()); - } - } - assert!(stack.is_empty(), "{name}: unclosed {stack:?}"); - } - - #[test] - fn pptx_package_parts_are_complete_and_consistent() { - let cell = |t: &str, x: f64, y: f64| Block { - text: t.into(), - rect: [x, y, x + 100.0, y + 14.0], - size: 12.0, - bold: false, - italic: false, - color: [0.0; 3], - rotation: 0.0, - space_around: [false; 2], - }; - let table_page = Page { - width: 612.0, - height: 792.0, - blocks: vec![ - cell("Metric", 72.0, 400.0), - cell("Value", 200.0, 400.0), - cell("Growth", 72.0, 360.0), - cell("+9%", 200.0, 360.0), - cell("Target", 72.0, 320.0), - cell("100%", 200.0, 320.0), - ], - images: Vec::new(), - background: None, - }; - let bytes = pptx(&[sample_page(), table_page], "Two"); - let parts = entries(&bytes); - assert_eq!(parts.first().map(|p| p.0.as_str()), Some("[Content_Types].xml"), "content types come first"); - let names: std::collections::HashSet<&str> = parts.iter().map(|p| p.0.as_str()).collect(); - assert_eq!(names.len(), parts.len(), "no duplicate parts"); - for required in [ - "[Content_Types].xml", - "_rels/.rels", - "ppt/presentation.xml", - "ppt/_rels/presentation.xml.rels", - "ppt/slideMasters/slideMaster1.xml", - "ppt/slideMasters/_rels/slideMaster1.xml.rels", - "ppt/slideLayouts/slideLayout1.xml", - "ppt/slideLayouts/_rels/slideLayout1.xml.rels", - "ppt/theme/theme1.xml", - "ppt/slides/slide1.xml", - "ppt/slides/_rels/slide1.xml.rels", - "ppt/slides/slide2.xml", - "ppt/slides/_rels/slide2.xml.rels", - "ppt/media/image1.png", - ] { - assert!(names.contains(required), "missing {required}"); - } - let text = |n: &str| String::from_utf8(parts.iter().find(|p| p.0 == n).map(|p| p.1.clone()).unwrap_or_default()).expect("UTF-8 XML"); - for (name, body) in &parts { - if name.ends_with(".xml") || name.ends_with(".rels") { - assert!(body.starts_with(b""), "the second slide carries the table"); - // Text boxes, not placeholders: the blank layout defines none to inherit from. - for slide in ["ppt/slides/slide1.xml", "ppt/slides/slide2.xml"] { - assert!(!text(slide).contains(" = base.split('/').filter(|s| !s.is_empty()).collect(); - for seg in target.split('/') { - if seg == ".." { - path.pop(); - } else { - path.push(seg); - } - } - let resolved = path.join("/"); - assert!(names.contains(resolved.as_str()), "{rels}: target {target} is missing"); - } - } - // The theme's font scheme carries the schema-required latin, ea and cs faces. - let theme = text("ppt/theme/theme1.xml"); - for font in ["majorFont", "minorFont"] { - let inner = theme.split(&format!("")).nth(1).and_then(|r| r.split(&format!("")).next()).unwrap_or_default(); - assert!(inner.contains(""), "the slide is still written"); - assert!(!slide.contains("y=\"-"), "positions never wrap negative"); - assert!(!slide.contains("")); - let slide_xml = part(&bytes, "ppt/slides/slide1.xml"); - assert!(slide_xml.contains("")); - } - - #[test] - fn pptx_slide_dimensions_and_clamping() { - // Known dimensions: 720x540 pt (10x7.5 in) = 9144000x6858000 EMU - let p = Page { width: 720.0, height: 540.0, ..Page::default() }; - let bytes = pptx(&[p], "Known Dimensions"); - let pres_xml = part(&bytes, "ppt/presentation.xml"); - assert!(pres_xml.contains(""), "{pres_xml}"); - - // Tiny page (10x10 pt) clamps to PowerPoint minimum of 1 inch (914400 EMU) - let tiny = Page { width: 10.0, height: 10.0, ..Page::default() }; - let tiny_bytes = pptx(&[tiny], "Tiny"); - let tiny_pres = part(&tiny_bytes, "ppt/presentation.xml"); - assert!(tiny_pres.contains(""), "{tiny_pres}"); - - // Poster page (10000x10000 pt) clamps to PowerPoint maximum of 56 inches (51206400 EMU) - let poster = Page { width: 10_000.0, height: 10_000.0, ..Page::default() }; - let poster_bytes = pptx(&[poster], "Poster"); - let poster_pres = part(&poster_bytes, "ppt/presentation.xml"); - assert!(poster_pres.contains(""), "{poster_pres}"); - - // Non-finite dimensions fall back to default - let nan_page = Page { width: f64::NAN, height: -1.0, ..Page::default() }; - let nan_bytes = pptx(&[nan_page], "NaN"); - let nan_pres = part(&nan_bytes, "ppt/presentation.xml"); - assert!(nan_pres.contains(""), "{nan_pres}"); - } - - #[test] - fn pptx_table_cell_with_largest_font_stays_in_table_and_not_in_title() { - let cell = |t: &str, x: f64, y: f64, w: f64, sz: f64| Block { - text: t.into(), - rect: [x, y, x + w, y + 14.0], - size: sz, - bold: false, - italic: false, - color: [0.0; 3], - rotation: 0.0, - space_around: [false; 2], - }; - let p = Page { - width: 720.0, - height: 540.0, - blocks: vec![ - // Heading outside the table (short width, does not span table columns) - Block { - text: "Monthly Summary".into(), - rect: [50.0, 480.0, 150.0, 500.0], - size: 20.0, - bold: true, - italic: false, - color: [0.0; 3], - rotation: 0.0, - space_around: [false; 2], - }, - // Table where one cell has a very large number (e.g. 36 pt callout in the table) - cell("Metric", 72.0, 400.0, 100.0, 12.0), - cell("Value", 200.0, 400.0, 100.0, 12.0), - cell("Growth", 72.0, 360.0, 100.0, 12.0), - cell("+99%", 200.0, 360.0, 100.0, 36.0), - cell("Target", 72.0, 320.0, 100.0, 12.0), - cell("100%", 200.0, 320.0, 100.0, 12.0), - ], - images: Vec::new(), - background: None, - }; - let bytes = pptx(&[p], "Table Check"); - let slide_xml = part(&bytes, "ppt/slides/slide1.xml"); - // Title should be the block outside the table ("Monthly Summary"), not the "+99%" cell - assert!(slide_xml.contains("Monthly Summary")); - assert!(slide_xml.contains("name=\"Title 2\"")); - // The table must contain both the cell and explicit borders - assert!(slide_xml.contains("")); - assert!(slide_xml.contains("+99%")); - assert!(slide_xml.contains("")); - } - - #[test] - fn pptx_opens_in_libreoffice() { - let p = sample_page(); - let bytes = pptx(&[p], "LibreOffice Check"); - let dir = std::env::temp_dir().join(format!("pdfcraft-pptx-lo-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - let _ = std::fs::create_dir_all(&dir); - let pptx_path = dir.join("test.pptx"); - std::fs::write(&pptx_path, &bytes).unwrap(); - - for cmd in ["libreoffice", "soffice"] { - let status = std::process::Command::new(cmd).args(["--headless", "--convert-to", "pdf", "--outdir"]).arg(&dir).arg(&pptx_path).status(); - if status.is_ok_and(|st| st.success()) { - assert!(dir.join("test.pdf").is_file(), "LibreOffice produced test.pdf"); - break; - } - } - let _ = std::fs::remove_dir_all(&dir); - } -} +mod tests; diff --git a/crates/export/src/pptx/tests.rs b/crates/export/src/pptx/tests.rs new file mode 100644 index 0000000000..2e1c1bd046 --- /dev/null +++ b/crates/export/src/pptx/tests.rs @@ -0,0 +1,393 @@ +use super::*; +use crate::Block; +use crate::tests::part; + +fn sample_page() -> Page { + Page { + width: 720.0, + height: 540.0, + blocks: vec![ + Block { + text: "Q3 Business Update".into(), + rect: [72.0, 460.0, 500.0, 500.0], + size: 28.0, + bold: true, + italic: false, + color: [0.1, 0.2, 0.3], + rotation: 0.0, + space_around: [false; 2], + }, + Block { + text: "Revenue grew 25% year over year across all regions.".into(), + rect: [72.0, 380.0, 500.0, 410.0], + size: 14.0, + bold: false, + italic: false, + color: [0.0; 3], + rotation: 0.0, + space_around: [false; 2], + }, + Block { + text: "Operational expenses remained flat.".into(), + rect: [72.0, 340.0, 500.0, 370.0], + size: 14.0, + bold: false, + italic: true, + color: [0.0; 3], + rotation: 0.0, + space_around: [false; 2], + }, + ], + images: vec![Image { + ext: "png", + bytes: b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0\x02\0\0\0\x03".as_slice().into(), + rect: [72.0, 150.0, 300.0, 300.0], + size: [228.0, 150.0], + rotation: 0.0, + }], + background: None, + } +} + +#[test] +pub fn pptx_is_a_presentationml_package() { + let p = sample_page(); + let bytes = pptx(&[p], "Executive Deck"); + assert!(bytes.starts_with(b"PK")); + for part_name in [ + "[Content_Types].xml", + "_rels/.rels", + "docProps/core.xml", + "ppt/presentation.xml", + "ppt/_rels/presentation.xml.rels", + "ppt/slideMasters/slideMaster1.xml", + "ppt/slideMasters/_rels/slideMaster1.xml.rels", + "ppt/slideLayouts/slideLayout1.xml", + "ppt/slideLayouts/_rels/slideLayout1.xml.rels", + "ppt/theme/theme1.xml", + "ppt/slides/slide1.xml", + "ppt/slides/_rels/slide1.xml.rels", + "ppt/media/image1.png", + ] { + assert!(bytes.windows(part_name.len()).any(|w| w == part_name.as_bytes()), "missing {part_name}"); + } +} + +#[test] +pub fn pptx_slide_has_title_body_and_images_in_order() { + let p = sample_page(); + let bytes = pptx(&[p], "Deck"); + let slide_xml = part(&bytes, "ppt/slides/slide1.xml"); + assert!(slide_xml.contains("name=\"Title 2\"")); + assert!(slide_xml.contains("txBox=\"1\"")); + assert!(slide_xml.contains("Q3 Business Update")); + assert!(slide_xml.contains("name=\"TextBox 3\"")); + assert!(slide_xml.contains("Revenue grew 25%")); + assert!(slide_xml.contains("")); + assert!(slide_xml.contains("")); + assert!(!slide_xml.contains("")); + assert!(slide_xml.contains("i=\"1\"")); +} + +/// Every entry of the package in order: (name, uncompressed bytes), read from the local +/// headers the way an unzipper walks them, so a broken structure fails here. +fn entries(zip: &[u8]) -> Vec<(String, Vec)> { + let at = |i: usize, n: usize| zip[i..i + n].iter().rev().fold(0usize, |v, b| v << 8 | *b as usize); + let mut out = Vec::new(); + let mut i = 0; + while zip[i..].starts_with(b"PK\x03\x04") { + let (method, size, raw, name_len, extra) = (at(i + 8, 2), at(i + 18, 4), at(i + 22, 4), at(i + 26, 2), at(i + 28, 2)); + let data = i + 30 + name_len + extra; + let name = String::from_utf8(zip[i + 30..i + 30 + name_len].to_vec()).expect("UTF-8 name"); + let body = &zip[data..data + size]; + let bytes = match method { + 0 => body.to_vec(), + 8 => { + let mut v = Vec::new(); + std::io::Read::read_to_end(&mut flate2::read::DeflateDecoder::new(body), &mut v).expect("inflates"); + v + } + m => panic!("{name}: unexpected method {m}"), + }; + assert_eq!(bytes.len(), raw, "{name}: uncompressed size"); + out.push((name, bytes)); + i = data + size; + } + assert!(zip[i..].starts_with(b"PK\x01\x02"), "the central directory follows the entries"); + out +} + +/// Checks that every start tag is closed by the matching end tag (text is escaped, so a +/// `<` always starts markup). Catches a typo such as `…`. +fn assert_tags_balance(name: &str, xml: &str) { + let mut stack: Vec<&str> = Vec::new(); + for chunk in xml.split('<').skip(1) { + let tag = chunk.split('>').next().unwrap_or_default(); + if tag.starts_with('?') { + continue; + } + if let Some(close) = tag.strip_prefix('/') { + assert_eq!(stack.pop(), Some(close.trim()), "{name}: closes the wrong element"); + } else if !tag.ends_with('/') { + stack.push(tag.split_whitespace().next().unwrap_or_default()); + } + } + assert!(stack.is_empty(), "{name}: unclosed {stack:?}"); +} + +#[test] +fn pptx_package_parts_are_complete_and_consistent() { + let cell = |t: &str, x: f64, y: f64| Block { + text: t.into(), + rect: [x, y, x + 100.0, y + 14.0], + size: 12.0, + bold: false, + italic: false, + color: [0.0; 3], + rotation: 0.0, + space_around: [false; 2], + }; + let table_page = Page { + width: 612.0, + height: 792.0, + blocks: vec![ + cell("Metric", 72.0, 400.0), + cell("Value", 200.0, 400.0), + cell("Growth", 72.0, 360.0), + cell("+9%", 200.0, 360.0), + cell("Target", 72.0, 320.0), + cell("100%", 200.0, 320.0), + ], + images: Vec::new(), + background: None, + }; + let bytes = pptx(&[sample_page(), table_page], "Two"); + let parts = entries(&bytes); + assert_eq!(parts.first().map(|p| p.0.as_str()), Some("[Content_Types].xml"), "content types come first"); + let names: std::collections::HashSet<&str> = parts.iter().map(|p| p.0.as_str()).collect(); + assert_eq!(names.len(), parts.len(), "no duplicate parts"); + for required in [ + "[Content_Types].xml", + "_rels/.rels", + "ppt/presentation.xml", + "ppt/_rels/presentation.xml.rels", + "ppt/slideMasters/slideMaster1.xml", + "ppt/slideMasters/_rels/slideMaster1.xml.rels", + "ppt/slideLayouts/slideLayout1.xml", + "ppt/slideLayouts/_rels/slideLayout1.xml.rels", + "ppt/theme/theme1.xml", + "ppt/slides/slide1.xml", + "ppt/slides/_rels/slide1.xml.rels", + "ppt/slides/slide2.xml", + "ppt/slides/_rels/slide2.xml.rels", + "ppt/media/image1.png", + ] { + assert!(names.contains(required), "missing {required}"); + } + let text = |n: &str| String::from_utf8(parts.iter().find(|p| p.0 == n).map(|p| p.1.clone()).unwrap_or_default()).expect("UTF-8 XML"); + for (name, body) in &parts { + if name.ends_with(".xml") || name.ends_with(".rels") { + assert!(body.starts_with(b""), "the second slide carries the table"); + // Text boxes, not placeholders: the blank layout defines none to inherit from. + for slide in ["ppt/slides/slide1.xml", "ppt/slides/slide2.xml"] { + assert!(!text(slide).contains(" = base.split('/').filter(|s| !s.is_empty()).collect(); + for seg in target.split('/') { + if seg == ".." { + path.pop(); + } else { + path.push(seg); + } + } + let resolved = path.join("/"); + assert!(names.contains(resolved.as_str()), "{rels}: target {target} is missing"); + } + } + // The theme's font scheme carries the schema-required latin, ea and cs faces. + let theme = text("ppt/theme/theme1.xml"); + for font in ["majorFont", "minorFont"] { + let inner = theme.split(&format!("")).nth(1).and_then(|r| r.split(&format!("")).next()).unwrap_or_default(); + assert!(inner.contains(""), "the slide is still written"); + assert!(!slide.contains("y=\"-"), "positions never wrap negative"); + assert!(!slide.contains("")); + let slide_xml = part(&bytes, "ppt/slides/slide1.xml"); + assert!(slide_xml.contains("")); +} + +#[test] +fn pptx_slide_dimensions_and_clamping() { + // Known dimensions: 720x540 pt (10x7.5 in) = 9144000x6858000 EMU + let p = Page { width: 720.0, height: 540.0, ..Page::default() }; + let bytes = pptx(&[p], "Known Dimensions"); + let pres_xml = part(&bytes, "ppt/presentation.xml"); + assert!(pres_xml.contains(""), "{pres_xml}"); + + // Tiny page (10x10 pt) clamps to PowerPoint minimum of 1 inch (914400 EMU) + let tiny = Page { width: 10.0, height: 10.0, ..Page::default() }; + let tiny_bytes = pptx(&[tiny], "Tiny"); + let tiny_pres = part(&tiny_bytes, "ppt/presentation.xml"); + assert!(tiny_pres.contains(""), "{tiny_pres}"); + + // Poster page (10000x10000 pt) clamps to PowerPoint maximum of 56 inches (51206400 EMU) + let poster = Page { width: 10_000.0, height: 10_000.0, ..Page::default() }; + let poster_bytes = pptx(&[poster], "Poster"); + let poster_pres = part(&poster_bytes, "ppt/presentation.xml"); + assert!(poster_pres.contains(""), "{poster_pres}"); + + // Non-finite dimensions fall back to default + let nan_page = Page { width: f64::NAN, height: -1.0, ..Page::default() }; + let nan_bytes = pptx(&[nan_page], "NaN"); + let nan_pres = part(&nan_bytes, "ppt/presentation.xml"); + assert!(nan_pres.contains(""), "{nan_pres}"); +} + +#[test] +fn pptx_table_cell_with_largest_font_stays_in_table_and_not_in_title() { + let cell = |t: &str, x: f64, y: f64, w: f64, sz: f64| Block { + text: t.into(), + rect: [x, y, x + w, y + 14.0], + size: sz, + bold: false, + italic: false, + color: [0.0; 3], + rotation: 0.0, + space_around: [false; 2], + }; + let p = Page { + width: 720.0, + height: 540.0, + blocks: vec![ + // Heading outside the table (short width, does not span table columns) + Block { + text: "Monthly Summary".into(), + rect: [50.0, 480.0, 150.0, 500.0], + size: 20.0, + bold: true, + italic: false, + color: [0.0; 3], + rotation: 0.0, + space_around: [false; 2], + }, + // Table where one cell has a very large number (e.g. 36 pt callout in the table) + cell("Metric", 72.0, 400.0, 100.0, 12.0), + cell("Value", 200.0, 400.0, 100.0, 12.0), + cell("Growth", 72.0, 360.0, 100.0, 12.0), + cell("+99%", 200.0, 360.0, 100.0, 36.0), + cell("Target", 72.0, 320.0, 100.0, 12.0), + cell("100%", 200.0, 320.0, 100.0, 12.0), + ], + images: Vec::new(), + background: None, + }; + let bytes = pptx(&[p], "Table Check"); + let slide_xml = part(&bytes, "ppt/slides/slide1.xml"); + // Title should be the block outside the table ("Monthly Summary"), not the "+99%" cell + assert!(slide_xml.contains("Monthly Summary")); + assert!(slide_xml.contains("name=\"Title 2\"")); + // The table must contain both the cell and explicit borders + assert!(slide_xml.contains("")); + assert!(slide_xml.contains("+99%")); + assert!(slide_xml.contains("")); +} + +#[test] +fn pptx_opens_in_libreoffice() { + let p = sample_page(); + let bytes = pptx(&[p], "LibreOffice Check"); + let dir = std::env::temp_dir().join(format!("pdfcraft-pptx-lo-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let _ = std::fs::create_dir_all(&dir); + let pptx_path = dir.join("test.pptx"); + std::fs::write(&pptx_path, &bytes).unwrap(); + + for cmd in ["libreoffice", "soffice"] { + let status = std::process::Command::new(cmd).args(["--headless", "--convert-to", "pdf", "--outdir"]).arg(&dir).arg(&pptx_path).status(); + if status.is_ok_and(|st| st.success()) { + assert!(dir.join("test.pdf").is_file(), "LibreOffice produced test.pdf"); + break; + } + } + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/export/src/tests.rs b/crates/export/src/tests.rs new file mode 100644 index 0000000000..f630d98d5d --- /dev/null +++ b/crates/export/src/tests.rs @@ -0,0 +1,861 @@ +use super::*; + +/// A page of `n` one-word blocks, each in its own size and colour. +fn varied_page(n: usize) -> Page { + let blocks = (0..n) + .map(|i| Block { + text: "word".into(), + rect: [72.0, i as f64, 100.0, i as f64 + 1.0], + size: 4.0 + i as f64 * 0.5, + bold: false, + italic: false, + color: [(i % 256) as f64 / 255.0, (i / 256 % 256) as f64 / 255.0, 0.5], + rotation: 0.0, + space_around: [false; 2], + }) + .collect(); + Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None } +} + +fn lookup_steps(f: impl FnOnce()) -> usize { + LOOKUP_STEPS.with(|c| c.set(0)); + f(); + LOOKUP_STEPS.with(|c| c.get()) +} + +#[test] +fn many_distinct_sizes_are_counted_in_constant_steps_each() { + let pages = [varied_page(3000)]; + let steps = lookup_steps(|| { + body_size(&pages); + }); + assert!(steps <= 2 * 3000, "{steps} steps"); +} + +#[test] +fn many_distinct_colours_take_constant_steps_each() { + let pages = [varied_page(3000)]; + let mut out = String::new(); + let steps = lookup_steps(|| out = rtf(&pages)); + assert!(steps <= 4 * 3000, "{steps} steps"); + assert!(out.contains("\\cf3000 ") || out.contains("\\cf3000\\"), "the last colour has the last slot"); +} + +fn page() -> Page { + let b = |t: &str, y: f64, size: f64, bold: bool| Block { + text: t.into(), + rect: [72.0, y, 500.0, y + size], + size, + bold, + italic: false, + color: [0.0; 3], + rotation: 0.0, + space_around: [false; 2], + }; + Page { + width: 612.0, + height: 792.0, + blocks: vec![ + b("Body text after the picture, which is long enough to be the main text size of the page.", 400.0, 11.0, false), + b("Annual Report", 700.0, 24.0, true), + b("Overview", 650.0, 14.0, true), + b("Some text & more of it so that eleven points is the body size here.", 620.0, 11.0, false), + ], + images: vec![Image { + ext: "png", + bytes: b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0\x02\0\0\0\x03".as_slice().into(), + rect: [72.0, 450.0, 272.0, 600.0], + size: [200.0, 150.0], + rotation: 0.0, + }], + background: None, + } +} + +#[test] +fn html_has_headings_paragraphs_and_images_in_order() { + let h = html(&[page(), page()], "Report"); + let order: Vec = ["

Annual Report

", "

Overview

", "

Some <body> text &", "Body text after"] + .iter() + .map(|x| h.find(x).unwrap_or_else(|| panic!("{x} missing in {h}"))) + .collect(); + assert!(order.windows(2).all(|w| w[0] < w[1]), "{order:?}"); + assert_eq!(h.matches("


").count(), 1); + assert_eq!(base64(b"Man"), "TWFu"); + assert_eq!(base64(b"Ma"), "TWE="); +} + +#[test] +fn simple_html_has_no_css_or_layout_elements() { + let mut p = page(); + let mut unicode = block("naïve 日本語.", 72.0, 300.0, 300.0, 311.0); + unicode.bold = true; + unicode.italic = true; + p.blocks.push(unicode); + let source = html_simple(&[p, table_page()], "Simple export"); + for banned in ["", "", "", " = + ["[Content_Types].xml", "word/document.xml", "word/styles.xml", "word/_rels/document.xml.rels", "word/media/image1.png", "_rels/.rels"] + .to_vec(); + for n in names { + assert!(d.windows(n.len()).any(|w| w == n.as_bytes()), "{n}"); + } +} + +#[test] +fn word_does_not_invent_borders_for_aligned_invoice_fields() { + let xml = part(&docx(&[table_page()], "Invoice"), "word/document.xml"); + assert!(xml.contains("")); + assert!(!xml.contains("w:val=\"single\""), "text alignment is not evidence of a border: {xml}"); + assert!(xml.contains("")); +} + +#[test] +fn word_table_cell_widths_follow_the_columns_they_span() { + let cell = |text: &str, span| Cell { text: text.into(), size: 11.0, bold: false, italic: false, color: [0.0; 3], span }; + let table = Table { + rect: [0.0, 0.0, 300.0, 100.0], + cols: vec![0.0, 50.0, 150.0], + rows: vec![ + vec![cell("First", 1), cell("Second", 1), cell("Third", 1)], + vec![cell("First", 1), cell("Last two", 2)], + vec![cell("First two", 2), cell("Third", 1)], + vec![cell("All", 3)], + vec![cell("First", 1)], + ], + }; + let xml = docx_table(&table); + assert!(xml.contains(""), "{xml}"); + let widths: Vec> = + xml.split("").skip(1).map(|row| row.split(""), "the picture stays inline: {xml}"); + assert!(xml.contains(""), "the title stays a heading: {xml}"); + let (title, body) = (xml.find(">Annual Report"), xml.find(">Body text after the picture")); + assert!(title.is_some() && body.is_some() && title < body, "reading order kept: {xml}"); + // The same page with the picture moved over the body text floats. + let mut over = page(); + over.images[0].rect = [72.0, 380.0, 272.0, 530.0]; + let xml = part(&docx(&[over], "Report"), "word/document.xml"); + assert!(xml.contains("position:absolute"), "an image over text floats: {xml}"); +} + +#[test] +fn word_overlap_does_not_consume_invoice_body_space() { + let mut p = table_page(); + p.blocks.push(Block { + text: "DRAFT".into(), + rect: [100.0, 200.0, 500.0, 700.0], + size: 100.0, + bold: false, + italic: false, + color: [0.8; 3], + rotation: 0.0, + space_around: [false; 2], + }); + p.images = page().images; + let xml = part(&docx(&[p], "Invoice"), "word/document.xml"); + assert!(xml.contains("position:absolute"), "overlapping text must float: {xml}"); + assert!(!xml.contains(""), "images must not displace invoice text"); + assert!(!xml.contains(""), "the source text boxes must keep their positions"); + assert_eq!(xml.matches(">DRAFT").count(), 1); + assert_eq!(xml.matches("w:type=\"page\"").count(), 0); + assert!(xml.contains("margin-left:72.000pt;margin-top:160.000pt"), "Meter field keeps its source position: {xml}"); + let watermark = xml.split(">DRAFT").next().unwrap().rsplit(""), "{xml}"); +} + +#[test] +fn word_rotated_pages_keep_separate_page_sizes_and_unique_shapes() { + let mut p = table_page(); + p.blocks[0].rotation = 45.0; + let mut second = p.clone(); + second.width = 400.0; + second.height = 500.0; + let xml = part(&docx(&[p, second], "Two invoices"), "word/document.xml"); + assert!(xml.contains("rotation:315.000"), "PDF y-up becomes Word y-down"); + assert_eq!(xml.matches("").count(), 2); + assert!(xml.contains("")); + assert_eq!(xml.matches("id=\"Text1\"").count(), 1); + assert_eq!(xml.matches(">Meter").count(), 2); +} + +/// #2992: pages that share a background colour get it as the Word document's page colour +/// (with the setting that makes Word show it); differing or white backgrounds don't. +#[test] +fn docx_keeps_a_shared_page_background() { + let teal = Page { background: Some([0.0, 0.5, 0.5]), ..page() }; + let zip = docx(&[teal.clone(), teal.clone()], "Teal"); + let xml = part(&zip, "word/document.xml"); + assert!(xml.contains(""), "{xml}"); + assert!(part(&zip, "word/settings.xml").contains("")); + assert!(part(&zip, "[Content_Types].xml").contains("/word/settings.xml")); + assert!(part(&zip, "word/_rels/document.xml.rels").contains("Target=\"settings.xml\"")); + let other = Page { background: Some([1.0, 0.0, 0.0]), ..page() }; + for (pages, why) in [ + (vec![teal.clone(), other], "different colours"), + (vec![teal.clone(), page()], "a page without one"), + (vec![Page { background: Some([1.0; 3]), ..page() }], "white"), + (vec![page()], "none"), + ] { + let zip = docx(&pages, "Plain"); + assert!(!part(&zip, "word/document.xml").contains(" String { + let at = |i: usize, n: usize| zip[i..i + n].iter().rev().fold(0usize, |v, b| v << 8 | *b as usize); + let mut i = 0; + while zip[i..].starts_with(b"PK\x03\x04") { + let (size, name_len) = (at(i + 18, 4), at(i + 26, 2)); + let data = i + 30 + name_len; + if &zip[i + 30..data] == name.as_bytes() { + let mut s = String::new(); + std::io::Read::read_to_string(&mut flate2::read::DeflateDecoder::new(&zip[data..data + size]), &mut s).unwrap(); + return s; + } + i = data + size; + } + panic!("{name} missing") +} + +#[test] +fn word_opens_text_with_control_characters_and_long_pages() { + // #72: text extracted without a usable /ToUnicode holds C0 controls, which XML forbids; + // Word then refused the whole file. A till receipt is also taller than Word's 22 in. + let mut p = page(); + p.height = 2400.0; + p.blocks.push(Block { + text: "Total\u{0}\u{3}\u{c} 4.50\u{FFFF}\tGBP".into(), + rect: [72.0, 100.0, 300.0, 110.0], + size: f64::NAN, + bold: false, + italic: false, + color: [0.0; 3], + rotation: 0.0, + space_around: [false; 2], + }); + let xml = part(&docx(&[p.clone()], "Receipt\u{1}"), "word/document.xml"); + assert!(xml.contains(">Total 4.50\tGBP"), "{xml}"); + assert!(!xml.chars().any(|c| !allowed(c))); + assert!(xml.contains(""), "clamped to 22 in: {xml}"); + assert!(xml.contains(""), "a non-finite size falls back to 12 pt"); + assert!(rtf(&[p]).contains("Total 4.50\tGBP")); + // A narrow page keeps room for text: margins shrink. + let narrow = Page { width: 100.0, height: 200.0, ..page() }; + let xml = part(&docx(&[narrow], "Narrow"), "word/document.xml"); + assert!(xml.contains(""), "{xml}"); + assert!(xml.contains(""), "out-of-range values clamp: {xml}"); + // Black text keeps Word's default colour. + assert_eq!(xml.matches("Annual Report"), "{h}"); + assert!(h.contains("

"), "{h}"); + let r = rtf(&[p.clone()]); + assert!(r.contains("{\\colortbl;\\red13\\green59\\blue46;\\red0\\green255\\blue0;}"), "{r}"); + assert!(r.contains("\\cf1 Annual Report"), "{r}"); + let ppt = pptx(&[p], "Colour"); + let pxml = part(&ppt, "ppt/slides/slide1.xml"); + assert!(pxml.contains(""), "{pxml}"); + assert!(pxml.contains(""), "out-of-range values clamp: {pxml}"); + // A table cell keeps its colour too. + let mut t = table_page(); + t.blocks[0].color = [1.0, 0.0, 0.0]; + let xml = part(&docx(&[t.clone()], "Grid"), "word/document.xml"); + assert!(xml.contains(""), "{xml}"); + assert!(html(&[t], "Grid").contains("style=\"color:#FF0000\"")); +} + +#[test] +fn rtf_escapes_and_sizes() { + let mut p = page(); + p.blocks.push(Block { + text: "Café {x}".into(), + rect: [72.0, 100.0, 200.0, 110.0], + size: 10.0, + bold: false, + italic: true, + color: [0.0; 3], + rotation: 0.0, + space_around: [false; 2], + }); + let r = rtf(&[p]); + assert!(r.starts_with("{\\rtf1") && r.ends_with('}')); + assert!(r.contains("\\fs48\\b Annual Report")); + assert!(r.contains("\\fs20\\i Caf\\u233? \\{x\\}"), "{r}"); +} + +/// A 3-column table with a spanning header row and a hole in the last row. +fn table_page() -> Page { + let cell = |t: &str, x: f64, y: f64, w: f64| Block { + text: t.into(), + rect: [x, y, x + w, y + 12.0], + size: 11.0, + bold: false, + italic: false, + color: [0.0; 3], + rotation: 0.0, + space_around: [false; 2], + }; + Page { + width: 612.0, + height: 792.0, + blocks: vec![ + cell("Meter", 72.0, 620.0, 120.0), + cell("Unit", 232.0, 620.0, 80.0), + cell("Reading", 392.0, 620.0, 100.0), + cell("A1", 72.0, 590.0, 120.0), + cell("kWh", 232.0, 590.0, 80.0), + cell("37414.00", 392.0, 590.0, 100.0), + cell("A2", 72.0, 560.0, 120.0), + cell("kW", 232.0, 560.0, 80.0), + // A2's row is missing its third cell (an empty cell in the grid). + cell("Totals", 72.0, 530.0, 240.0), + Block { + text: "Notes follow the table and are long enough to be the body text size of the page here.".into(), + rect: [72.0, 460.0, 500.0, 471.0], + size: 11.0, + bold: false, + italic: false, + color: [0.0; 3], + rotation: 0.0, + space_around: [false; 2], + }, + ], + images: Vec::new(), + background: None, + } +} + +#[test] +fn grid_text_becomes_a_real_table_in_every_format() { + let p = table_page(); + let h = html(std::slice::from_ref(&p), "Bill"); + assert!(h.contains("

"), "{h}"); + assert!(h.contains(""), "spanning cell: {h}"); + assert_eq!(h.matches("").count(), 4, "{h}"); + assert!(h.contains(""), "the hole is an empty cell: {h}"); + assert!(h.contains("

Notes follow"), "the paragraph after the table stays: {h}"); + + let d = docx(std::slice::from_ref(&p), "Bill"); + assert!(d.starts_with(b"PK")); + assert!(d.windows(b"word/document.xml".len()).any(|w| w == b"word/document.xml")); + let (tables, _) = tables(&p.blocks); + assert_eq!(tables.len(), 1); + let table_xml = docx_table(&tables[0]); + assert!(table_xml.contains("")); + assert!(table_xml.contains(""), "{table_xml}"); + assert_eq!(table_xml.matches("").count(), 4); + + let r = rtf(std::slice::from_ref(&p)); + assert!(r.contains("\\trowd"), "{r}"); + assert_eq!(r.matches("\\row").count(), 4); + // Boundaries precede the cells; "Totals" spans two columns, so it ends at the third edge. + assert!(r.contains("\\trowd\\trgaph108\\cellx4640\\cellx7840\\cellx9920{"), "{r}"); + assert!(r.contains("\\trowd\\trgaph108\\cellx7840{\\intbl\\fs22 Totals}\\cell\\row"), "{r}"); + + let pt = pptx(std::slice::from_ref(&p), "Bill"); + assert!(pt.starts_with(b"PK")); + assert!(pt.windows(b"ppt/slides/slide1.xml".len()).any(|w| w == b"ppt/slides/slide1.xml")); + let slide_xml = part(&pt, "ppt/slides/slide1.xml"); + assert!(slide_xml.contains("")); + assert!(slide_xml.contains("gridSpan=\"2\"")); + assert_eq!(slide_xml.matches(" = (0..n) + .map(|i| { + let (row, col) = (i / 2, i / 2 + i % 2); + let (x, y) = (10.0 + col as f64 * 20.0, 10_000.0 - row as f64 * 14.0); + Block { + text: "x".into(), + rect: [x, y, x + 8.0, y + 12.0], + size: 11.0, + bold: false, + italic: false, + color: [0.0; 3], + rotation: 0.0, + space_around: [false; 2], + } + }) + .collect(); + let started = std::time::Instant::now(); + let (tables, _) = tables(&blocks); + assert!(tables.iter().all(|t| t.cols.len() <= MAX_COLS), "{} columns", tables.iter().map(|t| t.cols.len()).max().unwrap_or(0)); + assert!(started.elapsed() < std::time::Duration::from_secs(5), "took {:?}", started.elapsed()); +} + +#[test] +fn word_opens_tables_up_to_its_column_limit() { + // Rows of short numbers on a regular grid, n columns wide. + let grid = |n: usize| Page { + width: 1500.0, + height: 792.0, + blocks: (0..3) + .flat_map(|row| { + (0..n).map(move |col| { + let (x, y) = (20.0 + col as f64 * 22.0, 700.0 - row as f64 * 14.0); + Block { + text: (col + 1).to_string(), + rect: [x, y, x + 8.0, y + 8.0], + size: 8.0, + bold: false, + italic: false, + color: [0.0; 3], + rotation: 0.0, + space_around: [false; 2], + } + }) + }) + .collect(), + images: Vec::new(), + background: None, + }; + let grid_cols = |p: &Page| part(&docx(std::slice::from_ref(p), "Grid"), "word/document.xml").matches(" Block { + Block { text: t.into(), rect: [x0, y0, x1, y1], size: 12.0, bold: false, italic: false, color: [0.0; 3], rotation: 0.0, space_around: [false; 2] } +} + +/// #2806: a sentence whose link is set in another colour or font runs on as separate blocks +/// on one line. They are not table columns, even when several such lines share left edges. +#[test] +fn a_link_inside_a_sentence_is_not_a_table_column() { + let blue = |t: &str, x0: f64, y0: f64, x1: f64, y1: f64| Block { color: [0.02, 0.388, 0.757], ..block(t, x0, y0, x1, y1) }; + let p = Page { + width: 612.0, + height: 792.0, + blocks: vec![ + block("6.3. 2. All content shown at", 121.7, 710.3, 272.0, 724.9), + blue("http", 273.5, 710.3, 294.1, 724.9), + blue("s", 294.1, 709.8, 298.8, 725.8), + blue("://women", 298.8, 710.3, 348.0, 724.9), + blue("g", 348.1, 709.8, 353.7, 725.8), + blue("randprix.fide.com", 353.7, 710.3, 443.2, 724.9), + block("shall be reviewed and approved by the Press Officer.", 164.3, 622.5, 533.1, 724.9), + block("6.3. 3. The Organiser shall warrant that", 121.7, 601.8, 345.1, 616.5), + blue("https://womengrandprix.fide.com", 350.2, 601.3, 519.9, 617.4), + block("is managed in a professional way and furthermore that it is capable of", 164.3, 557.9, 533.0, 616.5), + block("6. 3. 4. The Press Officer shall report to the FIDE Head of PR.", 121.7, 516.6, 530.3, 551.9), + block("conducted during a WGP tournament.", 164.3, 502.0, 352.5, 516.6), + ], + images: Vec::new(), + background: None, + }; + let h = html(std::slice::from_ref(&p), "Regulations"); + assert!(!h.contains("

Totals
"), "running text became a table: {h}"); + // The link is a run of its sentence, in its own colour (#4158). + assert!(h.contains("

6.3. 2. All content shown at http"), "{h}"); + for b in &p.blocks { + assert!(h.contains(&b.text), "{} was lost: {h}", b.text); + } +} + +/// #2806: lines that each run on into a link starting at the same place are sentences, not a +/// two-column table, even three in a row. +#[test] +fn sentences_that_run_on_into_links_are_not_a_table() { + let blue = |t: &str, x0: f64, y0: f64, x1: f64, y1: f64| Block { color: [0.02, 0.388, 0.757], ..block(t, x0, y0, x1, y1) }; + let mut blocks = Vec::new(); + for (n, lead) in ["Rules are published at", "Pairings are posted at", "Results are listed at"].iter().enumerate() { + let y = 700.0 - 16.0 * n as f64; + blocks.push(block(lead, 72.0, y, 211.0, y + 12.0)); + blocks.push(blue("https://example.org/event", 212.5, y, 360.0, y + 12.0)); + } + let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None }; + let h = html(std::slice::from_ref(&p), "Event"); + assert!(!h.contains("

"), "sentences became a table: {h}"); + assert_eq!(h.matches("https://example.org/event").count(), 3, "{h}"); +} + +/// A continuation can drift into a grid column covered by the preceding cell's span. +/// It must remain available as paragraph text if that slot never becomes an output cell. +#[test] +fn table_spans_do_not_consume_text_they_do_not_emit() { + let mut blocks = vec![ + Block { size: 12.0, ..block("ABCDEFGHIJK", 72.1, 697.0, 151.3, 710.8) }, + Block { size: 12.0, color: [0.0, 0.0, 1.0], ..block("LOST", 150.4, 697.0, 179.2, 710.8) }, + Block { size: 12.0, ..block("R0", 250.0, 697.0, 264.4, 710.8) }, + ]; + for row in 1..=4 { + let y = 697.0 - f64::from(row) * 50.0; + for (text, left, right) in [(format!("L{row}"), 72.1, 86.5), (format!("M{row}"), 146.4, 160.8), (format!("R{row}"), 250.0, 264.4)] { + blocks.push(Block { size: 12.0, ..block(&text, left, y, right, y + 13.8) }); + } + } + let p = Page { width: 612.0, height: 792.0, blocks, ..Page::default() }; + let (tables, consumed) = tables(&p.blocks); + assert_eq!(tables.len(), 1); + assert_eq!(tables[0].rows[0][0].span, 2); + assert!(!consumed[1], "a covered slot did not emit its text"); + assert!(consumed.iter().enumerate().all(|(index, used)| index == 1 || *used)); + let outputs = [ + html(std::slice::from_ref(&p), "Continuations"), + part(&docx(std::slice::from_ref(&p), "Continuations"), "word/document.xml"), + rtf(std::slice::from_ref(&p)), + part(&pptx(std::slice::from_ref(&p), "Continuations"), "ppt/slides/slide1.xml"), + ]; + for output in outputs { + for b in &p.blocks { + assert_eq!(output.matches(&b.text).count(), 1, "{} must survive exactly once", b.text); + } + } +} + +/// #2806: a superscript right after a number ("1" "st") continues its cell; the prize table +/// around it stays a table. +#[test] +fn a_superscript_beside_a_number_keeps_its_table() { + let mut blocks = vec![block("PLACE", 100.0, 700.0, 140.0, 712.0), block("PRIZE (euros)", 300.0, 700.0, 380.0, 712.0)]; + for (n, (row, sup)) in [("1", "st"), ("2", "nd"), ("3", "rd"), ("4", "th")].iter().enumerate() { + let y = 680.0 - 20.0 * n as f64; + blocks.push(block(row, 100.0, y, 106.0, y + 12.0)); + blocks.push(Block { size: 8.0, ..block(sup, 106.0, y + 5.0, 113.0, y + 13.0) }); + blocks.push(block(&format!("{},000", 30 - 5 * n), 300.0, y, 340.0, y + 12.0)); + } + let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None }; + let h = html(std::slice::from_ref(&p), "Prizes"); + assert!(h.contains("
"), "the prize table was lost: {h}"); + assert!(h.contains("30,000") && h.contains("15,000"), "{h}"); + let (tables, consumed) = tables(&p.blocks); + assert!(consumed.iter().all(|used| *used), "ordinary table continuations stay in their cells"); + let cells: Vec<&str> = tables.iter().flat_map(|t| &t.rows).flatten().map(|c| c.text.as_str()).collect(); + assert!(["st", "nd", "rd", "th"].iter().all(|suffix| cells.contains(suffix))); +} + +#[test] +fn two_column_body_text_is_not_mistaken_for_a_table() { + let para = |t: &str, x: f64, y: f64| Block { + text: t.into(), + rect: [x, y, x + 200.0, y + 90.0], // nine lines tall: body text, not a cell + size: 11.0, + bold: false, + italic: false, + color: [0.0; 3], + rotation: 0.0, + space_around: [false; 2], + }; + let p = Page { + width: 612.0, + height: 792.0, + blocks: vec![ + para("Left column text of the page, long enough to be a flowing paragraph.", 72.0, 500.0), + para("Right column text of the page, long enough to be a flowing paragraph.", 320.0, 500.0), + para("Second left block, still a tall flowing paragraph rather than a cell.", 72.0, 400.0), + para("Second right block, still a tall flowing paragraph rather than a cell.", 320.0, 400.0), + ], + images: Vec::new(), + background: None, + }; + let h = html(&[p], "Paper"); + assert!(!h.contains("
"), "tall two-column text must stay paragraphs: {h}"); +} + +/// #1936: every picture Word is told to draw (`r:embed`) names a relationship whose target is a +/// file in the package, with a content type; JPEG pictures too, inline or placed. +#[test] +fn word_pictures_resolve_to_media_in_the_package() { + let jpeg = Image { ext: "jpg", bytes: [0xFF, 0xD8, 0xFF, 0xD9].as_slice().into(), ..page().images[0].clone() }; + for placed in [false, true] { + let mut p = page(); + p.images = vec![jpeg.clone()]; + if placed { + p.images[0].rect = [72.0, 640.0, 272.0, 790.0]; + p.blocks[0].rect = [72.0, 700.0, 300.0, 760.0]; + } + let zip = docx(&[p], "T"); + let xml = part(&zip, "word/document.xml"); + assert_eq!(xml.contains(" Vec> { + let body = xml.split("").map(|s| s.split_once("").map_or(s, |(_, after)| after)).collect::(); + body.split("") + .skip(1) + .map(|p| { + let p = p.split_once("").map_or(p, |(inside, _)| inside); + p.split("") + .skip(1) + .filter_map(|r| { + let text = r.split_once("")?.1.split_once("")?.0; + Some((text.to_string(), r.contains(""))) + }) + .collect::>() + }) + .filter(|runs| !runs.is_empty()) + .collect() +} + +fn runs(r: &[(&str, bool)]) -> Vec<(String, bool)> { + r.iter().map(|(t, i)| (t.to_string(), *i)).collect() +} + +/// "Before *Chevron* after." on one line: the roman text, the italic case name and the roman +/// text after it, the way reading blocks split a sentence at its style changes (#4158). The +/// italic face stands a little taller than the roman one around it. +fn italic_sentence() -> Vec { + vec![ + block("Before", 72.0, 700.0, 110.0, 712.0), + Block { italic: true, ..block("Chevron", 113.0, 700.0, 158.0, 712.6) }, + block("after.", 161.0, 700.0, 190.0, 712.0), + ] +} + +/// #4158: a run in another style inside a sentence stays in that sentence's paragraph, as a +/// run in its own style, in every Office format. +#[test] +fn an_italic_word_stays_in_its_sentence() { + let mut blocks = italic_sentence(); + blocks.push(block("The next line is a paragraph of its own.", 72.0, 684.0, 300.0, 696.0)); + let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None }; + let xml = part(&docx(std::slice::from_ref(&p), "Opinion"), "word/document.xml"); + assert_eq!( + word_paragraphs(&xml), + vec![runs(&[("Before ", false), ("Chevron ", true), ("after.", false)]), runs(&[("The next line is a paragraph of its own.", false)])], + "{xml}" + ); + let h = html(std::slice::from_ref(&p), "Opinion"); + assert!(h.contains("

Before Chevron after.

"), "{h}"); + let r = rtf(std::slice::from_ref(&p)); + assert!(r.contains("{\\pard\\fs24 Before {\\fs24\\b0\\i\\cf0 Chevron }{\\fs24\\b0\\i0\\cf0 after.}\\par}"), "{r}"); + // The longest line is the slide's title; the sentence is one paragraph of the body. + let pxml = part(&pptx(std::slice::from_ref(&p), "Opinion"), "ppt/slides/slide1.xml"); + assert!( + pxml.contains("Before Chevron after."), + "{pxml}" + ); +} + +/// #4158: the runs keep their order along the line whatever order the blocks come in, and the +/// paragraph keeps its place on the page, though the italic run stands taller than the first. +#[test] +fn a_sentence_keeps_its_reading_order() { + let mut blocks = vec![block("Then the next line.", 72.0, 684.0, 200.0, 696.0)]; + blocks.extend(italic_sentence().into_iter().rev()); + let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None }; + let xml = part(&docx(std::slice::from_ref(&p), "Opinion"), "word/document.xml"); + assert_eq!( + word_paragraphs(&xml), + vec![runs(&[("Before ", false), ("Chevron ", true), ("after.", false)]), runs(&[("Then the next line.", false)])], + "{xml}" + ); +} + +/// The text of a block leaves out the spaces at its ends, and the box of a run often takes in +/// its trailing space, so runs can touch. The source's own space still separates them; runs +/// that touch with no space between them (a superscript) stay joined. +#[test] +fn runs_keep_the_spaces_the_source_had_between_them() { + let blocks = vec![ + Block { space_around: [false, true], ..block("Before", 72.0, 700.0, 113.0, 712.0) }, + Block { italic: true, ..block("Chevron", 113.0, 700.0, 158.0, 712.0) }, + Block { space_around: [true, false], ..block("after.", 158.0, 700.0, 190.0, 712.0) }, + block("E = mc", 72.0, 660.0, 110.0, 672.0), + Block { size: 8.0, ..block("2", 110.0, 665.0, 115.0, 673.0) }, + ]; + let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None }; + let xml = part(&docx(std::slice::from_ref(&p), "Spaces"), "word/document.xml"); + assert_eq!( + word_paragraphs(&xml), + vec![runs(&[("Before ", false), ("Chevron ", true), ("after.", false)]), runs(&[("E = mc", false), ("2", false)])], + "{xml}" + ); +} +/// Text turned another way (a label up the margin) is not a run of the line it touches. +#[test] +fn turned_text_is_not_a_run_of_the_line_beside_it() { + let blocks = vec![block("Before", 72.0, 700.0, 110.0, 712.0), Block { rotation: 90.0, ..block("SIDE", 111.0, 690.0, 123.0, 720.0) }]; + let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None }; + let h = html(std::slice::from_ref(&p), "Turned"); + assert!(h.contains("

Before

") && h.contains("

SIDE

"), "{h}"); +} +/// A block that starts where the line above ends is the next line, not a run of it. +#[test] +fn a_block_on_the_next_line_starts_a_paragraph() { + let blocks = vec![ + block("The first line ends here", 72.0, 700.0, 190.0, 712.0), + Block { italic: true, ..block("and the next starts there.", 190.5, 690.0, 320.0, 702.0) }, + ]; + let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None }; + let xml = part(&docx(std::slice::from_ref(&p), "Lines"), "word/document.xml"); + assert_eq!(word_paragraphs(&xml), vec![runs(&[("The first line ends here", false)]), runs(&[("and the next starts there.", true)])], "{xml}"); +} + +/// A superscript beside a table cell stays in the table: table detection consumes it before +/// paragraphs are formed, and a sentence after the table still keeps its italic run. +#[test] +fn table_cells_are_not_runs_of_a_paragraph() { + let mut blocks = vec![block("PLACE", 100.0, 700.0, 140.0, 712.0), block("PRIZE (euros)", 300.0, 700.0, 380.0, 712.0)]; + for (n, (row, sup)) in [("1", "st"), ("2", "nd"), ("3", "rd")].iter().enumerate() { + let y = 680.0 - 20.0 * n as f64; + blocks.push(block(row, 100.0, y, 106.0, y + 12.0)); + blocks.push(Block { size: 8.0, ..block(sup, 106.0, y + 5.0, 113.0, y + 13.0) }); + blocks.push(block(&format!("{},000", 30 - 5 * n), 300.0, y, 340.0, y + 12.0)); + } + blocks.extend(italic_sentence().into_iter().map(|b| Block { rect: [b.rect[0], b.rect[1] - 200.0, b.rect[2], b.rect[3] - 200.0], ..b })); + let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None }; + let xml = part(&docx(std::slice::from_ref(&p), "Prizes"), "word/document.xml"); + assert_eq!(xml.matches("").count(), 1, "{xml}"); + for cell in ["st", "nd", "rd", "30,000"] { + assert_eq!(xml.matches(&format!("preserve\">{cell}")).count(), 1, "{cell}: {xml}"); + } + // The cells are all in the table: the only flowing paragraph is the sentence. + assert_eq!(word_paragraphs(&xml), vec![runs(&[("Before ", false), ("Chevron ", true), ("after.", false)])], "{xml}"); +} + +/// A bold lead-in followed by body text on its line is body text, not a heading; a heading +/// with a smaller footnote marker after it stays a heading. +#[test] +fn a_paragraph_is_a_heading_only_when_its_text_is() { + let blocks = vec![ + Block { size: 24.0, ..block("Annual Report", 72.0, 740.0, 250.0, 764.0) }, + Block { size: 14.0, ..block("1", 251.0, 752.0, 258.0, 766.0) }, + Block { bold: true, ..block("Summary", 72.0, 700.0, 125.0, 712.0) }, + block("the year closed with more members than it opened with, and fewer debts.", 128.0, 700.0, 540.0, 712.0), + ]; + let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None }; + let xml = part(&docx(std::slice::from_ref(&p), "Report"), "word/document.xml"); + assert_eq!(xml.matches("").count(), 1, "{xml}"); + assert!(!xml.contains("Heading2"), "the lead-in made its paragraph a heading: {xml}"); + assert_eq!(word_paragraphs(&xml).len(), 2, "{xml}"); +} + +/// #1595: Markdown gets the headings, paragraphs and a rule between pages, in reading order; +/// images are left out. +#[test] +fn markdown_has_headings_paragraphs_and_page_rules_in_order() { + let md = markdown(&[page(), page()]); + let order: Vec = ["# Annual Report\n\n", "## Overview\n\n", "Some \\ text \\& more", "Body text after the picture", "\n\n---\n\n"] + .iter() + .map(|x| md.find(x).unwrap_or_else(|| panic!("{x:?} missing in {md}"))) + .collect(); + assert!(order.windows(2).all(|w| w[0] < w[1]), "{md}"); + assert_eq!(md.matches("\n---\n").count(), 1, "one rule between two pages: {md}"); + assert_eq!(md.matches("# Annual Report").count(), 2, "{md}"); + assert!(!md.contains("PNG") && !md.contains("!["), "images are left out: {md}"); + assert!(md.ends_with(".\n") && !md.ends_with("\n\n"), "{md:?}"); +} + +/// #1595: text Markdown would read as markup stays text, emphasis wraps a run without its outer +/// spaces, and a detected table becomes a pipe table with its first row as the header. +#[test] +fn markdown_escapes_markup_and_keeps_emphasis_and_tables() { + let b = |t: &str, y: f64, bold: bool, italic: bool| Block { + text: t.into(), + rect: [72.0, y, 500.0, y + 11.0], + size: 11.0, + bold, + italic, + color: [0.0; 3], + rotation: 0.0, + space_around: [false; 2], + }; + let p = Page { + width: 612.0, + height: 792.0, + blocks: vec![ + b("- not a list item, and long enough to make eleven points the body size here", 700.0, false, false), + b("1. not a numbered item either", 650.0, false, false), + b("*stars* _under_ `tick` [link](x) a|b # #tag\u{1}\nnext", 600.0, false, false), + b("Strong words in a sentence of their own.", 550.0, true, false), + b("slanted", 500.0, false, true), + ], + images: Vec::new(), + background: None, + }; + let md = markdown(&[p]); + assert!(md.contains("\\- not a list item"), "{md}"); + assert!(md.contains("1\\. not a numbered item"), "{md}"); + assert!(md.contains("\\*stars\\* \\_under\\_ \\`tick\\` \\[link\\](x) a\\|b \\# \\#tag next"), "{md}"); + assert!(md.contains("**Strong words in a sentence of their own.**"), "{md}"); + // Markdown reads no emphasis where a marker sits next to a space: the outer spaces go outside. + assert_eq!(emphasis(" two words ", true, false, false, false), " **two words** "); + assert_eq!(emphasis("both", true, true, true, true), "***both***"); + assert_eq!(emphasis(" ", true, true, true, true), " ", "nothing to mark"); + // Runs in one style are one: two bold runs side by side don't meet as `****`. + assert_eq!(paragraph(&[("hello", true, false), ("world", true, false)]), "**helloworld**"); + // Markers inside a word would stay literal in CommonMark, so a run there gets HTML tags. + assert_eq!(paragraph(&[("foo", false, false), (".", true, false), ("bar", false, false)]), "foo.bar"); + assert_eq!(paragraph(&[("x", true, false), ("y", false, true)]), "x*y*", "two styles side by side"); + // `*` may sit inside a word, and before punctuation that follows a letter. + assert_eq!(paragraph(&[("foo", false, false), ("bar", true, false), ("baz", false, false)]), "foo**bar**baz"); + assert_eq!(paragraph(&[("a ", false, false), ("b", true, false), (" c", false, false)]), "a **b** c"); + assert_eq!(paragraph(&[("say ", false, false), ("this", false, true), (", then", false, false)]), "say *this*, then"); + assert!(md.contains("*slanted*"), "{md}"); + assert!(!md.chars().any(|c| !allowed(c)), "{md:?}"); + + let md = markdown(&[table_page()]); + let rows: Vec<&str> = md.lines().filter(|l| l.starts_with('|')).collect(); + assert_eq!(rows.first(), Some(&"| Meter | Unit | Reading |"), "{md}"); + assert_eq!(rows.get(1), Some(&"| --- | --- | --- |"), "{md}"); + assert!(rows.contains(&"| A1 | kWh | 37414.00 |"), "{md}"); + assert!(rows.iter().all(|r| r.matches(" | ").count() == 2), "every row has the header's three cells: {md}"); +} diff --git a/crates/filters/Cargo.toml b/crates/filters/Cargo.toml index 9d2ae34b94..bfb930fe5f 100644 --- a/crates/filters/Cargo.toml +++ b/crates/filters/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pdfcraft-filters" -description = "PDF stream filters (ISO 32000-2 §7.4): Flate, LZW, predictors, ASCII85, ASCIIHex, RunLength" +description = "PDF stream filters (ISO 32000-2 §7.4): Flate, LZW, predictors, ASCII85, ASCIIHex, RunLength, CCITT Group 4 encoding and Group 3 and 4 decoding, JBIG2 encoding and decoding" version.workspace = true edition.workspace = true license.workspace = true @@ -11,6 +11,10 @@ repository.workspace = true workspace = true [dependencies] +fax.workspace = true +# CCITT fax and JBIG2 decoding (`decode_ccitt`, `decode_jbig2`); already in the tree through hayro. +hayro-ccitt.workspace = true +hayro-jbig2.workspace = true flate2.workspace = true thiserror.workspace = true diff --git a/crates/filters/README.md b/crates/filters/README.md index 959180d87a..90e1485177 100644 --- a/crates/filters/README.md +++ b/crates/filters/README.md @@ -3,7 +3,8 @@ PDF stream filters: the decoders and encoders behind `/Filter` and `/DecodeParms`. - **Layer:** L0, standalone. No dependency on any other PdfCraft crate. `cos` depends on it (architecture §3 rule 3). -- **Licence:** MIT OR Apache-2.0. Clean-room: written from ISO 32000-2 §7.4, RFC 1950/1951 (through `flate2` with the pure-Rust `miniz_oxide` backend), TIFF 6.0 §14 (predictor 2) and RFC 2083 §6 (PNG filters). +- **Licence:** MIT OR Apache-2.0. Clean-room: written from ISO 32000-2 §7.4, RFC 1950/1951 (through `flate2` with the pure-Rust `miniz_oxide` backend), TIFF 6.0 §14 (predictor 2) and RFC 2083 §6 (PNG filters). CCITT Group 4 encoding (ITU-T T.6) is the `fax` crate's (MIT, already in the tree through `tiff`), checked here by round trips through an independent decoder; CCITT decoding (T.4 and T.6) is the `hayro-ccitt` crate's, and JBIG2 decoding the `hayro-jbig2` crate's (both MIT OR Apache-2.0, already in the tree through hayro), bounded here. JBIG2 generic region encoding is written from the public ISO/IEC 14492 Final Committee Draft (WG1 N1359, 1999); Annex H.2 checks the arithmetic coder byte for byte. + - `#![forbid(unsafe_code)]`. Builds for `wasm32-unknown-unknown`. ## API @@ -14,20 +15,29 @@ Filter::from_name(b"FlateDecode" | b"Fl" | ...) -> Filter // full names and in Filter::name(&self) -> &str; Filter::is_image_codec(&self) -> bool pub struct Params { predictor, colors, bits_per_component, columns, early_change } // i64s; Default = 1, 1, 8, 1, 1 -pub enum FilterError { Corrupt { filter, detail }, LimitExceeded(usize), Unsupported(String) } +pub enum FilterError { Corrupt { filter, detail }, LimitExceeded(usize), Unsupported(String), Spent } decode(chain, data, max_output) -> Result, FilterError> decode_counted(chain, data, max_output) -> Result<(Vec, usize /*decoded*/), (FilterError, usize /*decoded*/)> decode_tolerant(chain, data, max_output) -> Result<(Vec, bool /*partial*/), FilterError> decode_tolerant_counted(chain, data, max_output) -> Result<(Vec, bool /*partial*/, usize /*decoded*/), (FilterError, usize /*decoded*/)> +decode_tolerant_within(chain, data, max_output, work: &mut u64) -> Result, FilterError> +decode_within(chain, data, max_output, work: &mut u64) -> Result, FilterError> encode(filter, params, data) -> Result, FilterError> encode_flate(data) -> Vec // zlib, level 6 +encode_g4(data, columns, rows) -> Result, FilterError> // 1-bit rows as CCITT Group 4; columns <= MAX_G4_COLUMNS +pub struct CcittParams { k, columns, rows, end_of_line, encoded_byte_align, end_of_block, black_is_1 } // Table 11; Default = its defaults +decode_ccitt(data, ¶ms, max_rows) -> Result, FilterError> // CCITT fax as CCITTFaxDecode gives it; columns <= MAX_CCITT_COLUMNS +decode_jbig2(data, globals, columns, rows, max) -> Result, FilterError> // JBIG2 as JBIG2Decode gives it; a page of columns × rows, within max and MAX_JBIG2_PIXELS +encode_jbig2_generic(data, columns, rows) -> Result, FilterError> // 1-bit rows as lossless JBIG2 for JBIG2Decode; columns <= MAX_JBIG2_COLUMNS +encode_jbig2(data, columns, rows) -> Result, FilterError> // 1-bit rows as lossless JBIG2 for JBIG2Decode, symbol coded when that is smaller; columns <= MAX_JBIG2_COLUMNS ``` - `chain` is applied first to last, as in a `/Filter` array. - Image codecs (DCT, JPX, JBIG2, CCITT), `Crypt` and unknown filters anywhere in a chain give `Unsupported`: callers keep such data encoded. So does a chain longer than `MAX_CHAIN` (8) filters, before any stage runs. Decryption belongs to `crypt`. - `max_output` bounds every intermediate buffer (including the pre-predictor buffer) and gives `LimitExceeded(max_output)`. This also holds when Flate retries raw deflate after a misleading zlib header, in both strict and tolerant decoding. - `decode_counted` and `decode_tolerant_counted` are for a caller charging a budget (`pdfcraft_cos::DecodeBudget`). `max_output` bounds all stages together (each stage gets what the earlier ones left), and they give the bytes every stage that ran decoded, on success and on failure. A stage's output counts as its decoder produced it, before a predictor or a later stage shrinks it, and a failed decode pays for what it decoded first. +- `decode_tolerant_within` is for a run of images (`pdfcraft_cos::ImageBudget`), as `decode_within` (its strict form), `decode_jbig2_within` and `decode_ccitt_within` are, all spending from one `work`, in the JBIG2 decoder's steps. `max_output` bounds each stage, as in `decode_tolerant`, and no stage decodes past what `work` has left. It pays a step a byte of `data` first and a step a byte each stage decodes, so a chain that fails has paid for what it did: 0.2 to 3 ns a byte in or out, up to 19 ns through a predictor on samples of a bit, against the 6 to 16 ns of a JBIG2 step. What `work` can't pay for spends all of it, and gives `Spent`; with nothing left nothing is read. A chain that can't be decoded at all costs nothing and gives its usual error. - `decode_tolerant` turns `Corrupt` into "what was decoded so far" plus `partial = true`, then runs the rest of the chain on it (predictors are applied to partial output too). `LimitExceeded` and `Unsupported` are still errors. ## Behaviour @@ -40,14 +50,24 @@ encode_flate(data) -> Vec // zlib, level 6 | ASCIIHexDecode | white space ignored, odd digit padded with 0, `>` EOD (optional), other characters → Corrupt | upper case, newline every 64 digits, `>` | | ASCII85Decode | `<~` prefix, `z`, white space, `~>` (or bare `~`, or none) EOD, final partial group; `z` inside a group, a 1-char final group, overflow or bad characters → Corrupt | `z` for zero groups, newline every 75 characters, `~>` | | RunLengthDecode | 0–127 literal, 129–255 repeat, 128 EOD (optional); truncated run → Corrupt | runs of ≥2 repeat, literals up to 128, 128 | +| CCITTFaxDecode | not in a chain (`Unsupported`); `decode_ccitt` decodes Group 3 and Group 4 on its own, as rows of 1-bit samples each on a new byte, black 0 unless `black_is_1`, at most `max_rows` rows (fewer with `/Rows`), the rows before any damage, up to 1,048,576 samples a row; Group 3 rows on bytes are read by their end-of-line codes when the data starts with one and on bytes otherwise, whatever `end_of_line` says, so data with codes but none before the first row (not as T.4 sends them) is read on bytes, as the renderer draws it, and at `k` 0 may come out as other rows (`pdfcraft_cos::Document::decoded_image` calls it for a fax image, after the filters before it) | `encode_g4`: Group 4 (`/K -1`) with an end-of-block mark, 0 bits coded black so `/BlackIs1 false` decodes the same bits; rows packed MSB first, each on a new byte, padding bits and trailing bytes ignored; up to 32,767 samples a row (the encoder counts positions in 16 bits); no rows or columns, or too wide → Unsupported, too few bytes → Corrupt | +| JBIG2Decode | not in a chain (`Unsupported`); `decode_jbig2` decodes it on its own with `hayro-jbig2`, given what its `/JBIG2Globals` decode to, as rows of 1-bit samples each on a new byte, black 0; the page has to be `columns` × `rows` (PdfCraft's renderer draws the whole page into the image), within `max` bytes as such rows and 2^28 pixels (`MAX_JBIG2_PIXELS`, about the most the decoder takes), checked before it is decoded; regions inside it are bounded by the decoder alone (a region may claim more pixels than its page); damaged data, or globals that don't decode, are `Corrupt` (`pdfcraft_cos::Document::decoded_image` calls it for a JBIG2 image, after the filters before it) | `encode_jbig2_generic`: the embedded organisation (D.3) with a page information segment and an immediate lossless generic region (7.4.6), no `/JBIG2Globals`; template 0 with its nominal AT pixels and typical prediction (6.2.5), coded with the MQ arithmetic coder (Annex E, Table E.1) and ended by its 0xFF 0xAC marker. A 0 sample is coded as a JBIG2 1 (black), since `JBIG2Decode` gives black as 0, so the stream decodes to the same bits; rows packed MSB first, each on a new byte, padding bits and trailing bytes ignored; up to 1,048,576 samples a row; no rows or columns, or too wide → Unsupported, too few bytes → Corrupt. `encode_jbig2`: the smaller of that and symbol coding (`jbig2/symbols.rs`): each distinct mark (8-connected black pixels, at most 256 a side) stored once in a symbol dictionary (7.4.2; arithmetic coded, each bitmap a template 0 generic region without typical prediction, the bitmaps sharing their contexts) and placed by immediate lossless text regions referring to it (7.4.3; bottom-left reference corner, strips of 1, 2, 4 or 8 rows, whichever is smallest), each of at most 10,000 instances over a band of rows, as the decoder PdfCraft renders with (`hayro-jbig2`) takes no more in one region, with larger marks, and in a second plan marks that occur once, in a generic region over their box. Only exact copies share a symbol (no refinement), so the stream decodes to the same bits; fewer than two symbols, more than 2^20 black runs, 65,536 distinct marks or 16 MiB of their pixels, or more than 2^24 rows, and the generic coding is used; a plan of more than 32,767 symbols (that decoder refuses more) isn't made | ## Tests - Unit tests per filter with hand-computed vectors (the ISO 32000-2 LZW example, RFC 1951 stored blocks, PNG/TIFF rows). - LZW is cross-checked against `weezl` (dev-dependency) in both directions for both early-change settings. - `tests/proptests.rs`: encode→decode round trips for every encodable filter × predictor × colors × bpc × columns, two-filter chains, output-limit checks, and fuzz-style no-panic tests (random bytes, random/nonsense params, bit-flipped valid streams). +- `ccitt.rs`: `decode_ccitt` gives back the rows `encode_g4` was given (with `/BlackIs1`, `/Rows` and a row limit), refuses bad widths, and bounds its output for any input; byte-aligned Group 3 in each layout, over 32 seeded pictures, decodes to its picture, and a byte short to fewer rows. Group 4 output decoded by `hayro-ccitt` (an independent decoder) matches the input bits: a text-like page, padding bits, the widest row, and property tests over bitmaps of noise and of runs; any input returns rather than panics. +- `mq.rs`: the arithmetic coder reproduces Annex H.2's 30 bytes from its 256 decisions. +- `jbig2.rs`: output decoded by `hayro-jbig2` (dev-dependency, an independent decoder) matches the input bits: a text-like page (smaller than Group 4), edges, padding, all-white and all-black regions (typical rows), and property tests over bitmaps of noise and of runs; any input returns rather than panics. +- `jbig2/decode.rs`: `decode_jbig2` gives back the rows of both codings, padding bits 0; refuses a page of another width or height, past `max` or past `MAX_JBIG2_PIXELS` before decoding it (2^28 pixels take about 34 MB, a bit each), globals that aren't JBIG2 and damaged data, and returns rather than panics on every prefix of a stream. +- `jbig2/symbols.rs`: the integer coders against Annex A's example and Table A.1; symbol-coded output decoded by `hayro-jbig2` matches the input bits for every plan and strip size: text whose letters repeat (under half the generic coding's size, the dictionary retained and referred to), large and lone marks left to a generic region, marks on the edges, touching at corners and inside each other's boxes, and property tests over glyphs scattered over each other and the edges with stray pixels (never larger than the generic coding); one shape, a blank page and noise are coded generically; marks joined at a corner, either way, are one mark, and 256 pixels a side is a symbol while 257 wide or tall is left out; black padding bits are ignored; coordinates past 4,435 use Annex A's 32-bit range and PREV stays below 512; past 2^20 runs, 2^24 rows, 65,536 distinct marks or 2^24 symbol pixels the image is coded generically, and at each cap it is still looked at; a plan codes at most 32,767 symbols, which decode, and past that the second plan still codes the repeated ones; more than 10,000 marks are placed by several text regions, numbered in turn, the dictionary retained for each but the last; any input returns rather than panics. - `fuzz/`: a detached cargo-fuzz crate (`cargo +nightly fuzz run decode`, run from `crates/filters/`). ## Status -M1.1/M1.2 for the general-purpose filters are done. Not yet here: the image codecs (DCT via zune-jpeg, JPX via hayro-jpeg2000, JBIG2, CCITT G3/G4 decode and G4 encode, DCT encode) and Flate encode levels other than 6. +M1.1/M1.2 for the general-purpose filters are done. Not yet here: the DCT and JPX decoders (DCT via zune-jpeg, JPX via hayro-jpeg2000), DCT encoding, CCITT Group 3 encoding and Flate encode levels other than 6. + + +Lossless symbol coding is also implemented: exact-copy dictionaries/text regions, bounded by the encoder; no approximate or lossy symbol substitution. diff --git a/crates/filters/src/ccitt.rs b/crates/filters/src/ccitt.rs new file mode 100644 index 0000000000..2a038568ee --- /dev/null +++ b/crates/filters/src/ccitt.rs @@ -0,0 +1,547 @@ +//! CCITT fax for `CCITTFaxDecode` (ISO 32000-2 §7.4.6): Group 4 (ITU-T T.6) encoding, with `/K -1`, +//! and decoding of Group 3 (T.4) and Group 4. The coding is the `fax` crate's and the decoding the +//! `hayro-ccitt` crate's; this module checks what they are given and bounds what they give. + +use fax::encoder::Encoder; +use fax::{Color, VecWriter}; + +use crate::FilterError; + +/// The widest row [`encode_g4`] encodes. The encoder counts the positions in a row in 16 bits +/// and adds two of them, so a row stays within half that range. +pub const MAX_G4_COLUMNS: u32 = 32_767; + +/// `rows` rows of `columns` 1-bit samples, most significant bit first, each row starting on a new +/// byte (as a PDF image's samples are packed), encoded as CCITT Group 4 with an end-of-block mark. +/// +/// A 0 bit is coded black and a 1 bit white, so the stream decodes to the same bits with +/// `/BlackIs1 false`, the default: an image keeps its colour space and decode array. Bytes after +/// the last row are ignored, as are the bits that pad a row to a whole byte. +/// +/// Errors: no rows or columns, or rows wider than [`MAX_G4_COLUMNS`] ([`FilterError::Unsupported`]); +/// fewer bytes than the rows need ([`FilterError::Corrupt`]). +pub fn encode_g4(data: &[u8], columns: u32, rows: u32) -> Result, FilterError> { + let width = match u16::try_from(columns) { + Ok(w) if w > 0 && columns <= MAX_G4_COLUMNS => w, + _ => return Err(FilterError::Unsupported(format!("CCITT Group 4 encoding of rows {columns} samples wide"))), + }; + if rows == 0 { + return Err(FilterError::Unsupported("CCITT Group 4 encoding of an image without rows".into())); + } + let stride = usize::from(width).div_ceil(8); + let samples = usize::try_from(rows).ok().and_then(|r| r.checked_mul(stride)).and_then(|n| data.get(..n)).ok_or_else(|| FilterError::Corrupt { + filter: "CCITTFaxDecode", + detail: format!("{} bytes for {rows} rows of {columns} samples", data.len()), + })?; + let mut encoder = Encoder::new(VecWriter::new()); + for row in samples.chunks_exact(stride) { + let pels = (0..usize::from(width)).map(|x| { + let white = row.get(x / 8).is_some_and(|b| b & (0x80 >> (x % 8)) != 0); + if white { Color::White } else { Color::Black } + }); + // `VecWriter` writes to memory: its error type has no values. + if let Err(never) = encoder.encode_line(pels, width) { + match never {} + } + } + match encoder.finish() { + Ok(writer) => Ok(writer.finish()), + Err(never) => match never {}, + } +} + +/// The widest row [`decode_ccitt_within`] decodes: its row buffer stays within 128 KiB. +pub const MAX_CCITT_COLUMNS: u32 = 1 << 20; + +/// A `CCITTFaxDecode` stream's `/DecodeParms` (ISO 32000-2 Table 11). [`Default`] gives the +/// table's defaults. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct CcittParams { + /// `/K`: negative for Group 4, 0 for Group 3 one-dimensional, positive for Group 3 mixed. + pub k: i64, + pub columns: i64, + /// `/Rows`: 0 when not given. + pub rows: i64, + pub end_of_line: bool, + pub encoded_byte_align: bool, + pub end_of_block: bool, + pub black_is_1: bool, +} + +impl Default for CcittParams { + fn default() -> Self { + CcittParams { k: 0, columns: 1728, rows: 0, end_of_line: false, encoded_byte_align: false, end_of_block: true, black_is_1: false } + } +} + +/// What [`decode_ccitt_within`] decodes once it is paid for. +fn decode_ccitt(data: &[u8], p: &CcittParams, max_rows: u32) -> Result, FilterError> { + let columns = u32::try_from(p.columns) + .ok() + .filter(|c| (1..=MAX_CCITT_COLUMNS).contains(c)) + .ok_or_else(|| FilterError::Unsupported(format!("CCITT fax rows {} samples wide", p.columns)))?; + let rows = u32::try_from(p.rows).ok().filter(|r| *r > 0).map_or(max_rows, |r| r.min(max_rows)); + if rows == 0 { + return Ok(Vec::new()); + } + let settings = hayro_ccitt::DecodeSettings { + columns, + rows, + end_of_block: p.end_of_block, + end_of_line: p.end_of_line, + // Group 3 rows on bytes may have end-of-line codes whatever `end_of_line` says (the filter + // always accepts them), with the fill starting each code on a byte or ending it on one (T.4's + // fill, as TIFF writes it). Read unaligned, the codes mark the rows either way; without + // codes, the rows are read on bytes. A reading that comes up short isn't tried the other + // way: data cut short would then read as other rows. + rows_are_byte_aligned: p.encoded_byte_align && (p.k < 0 || !starts_with_end_of_line(data)), + encoding: match p.k { + ..0 => hayro_ccitt::EncodingMode::Group4, + 0 => hayro_ccitt::EncodingMode::Group3_1D, + k => hayro_ccitt::EncodingMode::Group3_2D { k: u32::try_from(k).unwrap_or(u32::MAX) }, + }, + invert_black: false, + }; + // u32 to usize: wasm32 and up have at least 32-bit usize. + let mut out = Rows::new(columns as usize, rows as usize, p.black_is_1); + // The rows decoded before damaged data are kept. + let _ = hayro_ccitt::decode(data, &mut out, &mut hayro_ccitt::DecoderContext::new(settings)); + Ok(out.out) +} + +/// The steps of reading a byte of fax code ([`decode_ccitt_within`]): noise takes 114 ns a byte of +/// rows and about two bytes of code to a byte of rows, so that and a step a byte of rows is about +/// what a step of JBIG2 decoding takes. +const CODE_WORK: u64 = 4; + +/// CCITT fax `data` decoded as `CCITTFaxDecode` gives it: rows of `columns` 1-bit samples, most +/// significant bit first, each starting on a new byte, with black as 0 (as 1 with `black_is_1`). +/// +/// At most `max_rows` rows are decoded (fewer when `rows` says so), so the output is bounded by +/// the caller. Damaged data gives the rows decoded before the damage, as viewers show them: a +/// caller that needs a number of rows checks it got them. +/// +/// Group 3 rows on bytes (`k` from 0, `encoded_byte_align`) are read by their end-of-line codes, +/// whether the fill starts each code on a byte or ends it on one, when `data` starts with one (T.4 +/// sends one before the first row when it sends them at all), and on bytes otherwise, whatever +/// `end_of_line` says. Group 4 is read as its parameters say. Data with codes but none before the +/// first row is read on bytes, as PdfCraft's renderer draws it; with `k` 0 and the fill ending each +/// code on a byte, that may be other rows than were coded. +/// +/// Errors: `columns` that aren't from 1 to [`MAX_CCITT_COLUMNS`] ([`FilterError::Unsupported`]). +/// +/// Decoding spends from `work`, what decoding may still cost across a run of images, and there is +/// no way in without it: a +/// byte of rows a step, whether or not the data holds them, and [`CODE_WORK`] a byte of `data`. +/// A blank row takes a bit of code, so the rows are what a few bytes can ask for. It is all paid +/// before anything is decoded, and what `work` can't pay for spends all of it. +pub fn decode_ccitt_within(data: &[u8], p: &CcittParams, max_rows: u32, work: &mut u64) -> Result, FilterError> { + let rows = u32::try_from(p.rows).ok().filter(|r| *r > 0).map_or(max_rows, |r| r.min(max_rows)); + let stride = u64::try_from(p.columns).unwrap_or(0).div_ceil(8); + crate::pay(work, stride.saturating_mul(u64::from(rows)).saturating_add((data.len() as u64).saturating_mul(CODE_WORK)))?; + decode_ccitt(data, p, max_rows) +} + +/// Whether CCITT fax `data` starts with an end-of-line code: a 1 after 11 zeros or more, fill +/// included. T.4 sends one before the first row when it sends them at all, and no other code starts +/// with more than eight zeros. +fn starts_with_end_of_line(data: &[u8]) -> bool { + let Some((i, b)) = data.iter().enumerate().find(|(_, b)| **b != 0) else { return false }; + i.saturating_mul(8).saturating_add(b.leading_zeros() as usize) >= 11 +} + +/// Rows of 1-bit samples filled from a decoder's pixels: `columns` a row, each row on a new byte, at +/// most `rows` rows. Pixels past a row's end and rows past the last are dropped. The JBIG2 decoder +/// ([`crate::decode_jbig2_within`]) fills them too. +pub(crate) struct Rows { + pub(crate) out: Vec, + row: Vec, + x: usize, + columns: usize, + rows: usize, + done: usize, + black_is_1: bool, +} + +impl Rows { + pub(crate) fn new(columns: usize, rows: usize, black_is_1: bool) -> Self { + Rows { out: Vec::new(), row: vec![0; columns.div_ceil(8)], x: 0, columns, rows, done: 0, black_is_1 } + } + + /// `count` pixels, white or black. + pub(crate) fn push(&mut self, white: bool, count: usize) { + let end = self.x.saturating_add(count); + if white != self.black_is_1 { + for x in self.x..end.min(self.columns) { + if let Some(b) = self.row.get_mut(x / 8) { + *b |= 0x80 >> (x % 8); + } + } + } + self.x = end; + } + + /// The row filled so far kept, if fewer than `rows` are, and the next one begun. + pub(crate) fn end_row(&mut self) { + if self.done < self.rows { + self.out.extend_from_slice(&self.row); + self.done += 1; + } + self.row.fill(0); + self.x = 0; + } +} + +impl hayro_ccitt::Decoder for Rows { + fn push_pixel(&mut self, white: bool) { + self.push(white, 1); + } + fn push_pixel_chunk(&mut self, white: bool, chunks: u32) { + self.push(white, (chunks as usize).saturating_mul(8)); + } + fn next_line(&mut self) { + self.end_row(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A run pays for the rows an image asks for and the code it reads, before any of it is + /// decoded: rows the data doesn't hold are paid for too, as a few bytes can ask for any number. + #[test] + fn a_run_of_images_pays_for_the_rows_it_asks_for() { + let (w, h) = (4096u32, 64u32); + let stride = (w as usize).div_ceil(8); + let data = encode_g4(&vec![0xFF; stride * h as usize], w, h).unwrap(); + let p = CcittParams { k: -1, columns: i64::from(w), ..CcittParams::default() }; + let whole = decode_ccitt(&data, &p, h).unwrap(); + let cost = (whole.len() + 4 * data.len()) as u64; + let mut work = cost; + assert_eq!(decode_ccitt_within(&data, &p, h, &mut work).unwrap(), whole, "exactly enough"); + assert_eq!(work, 0); + let mut work = cost + 7; + decode_ccitt_within(&data, &p, h, &mut work).unwrap(); + assert_eq!(work, 7, "only what it took"); + let mut work = cost - 1; + let refused = decode_ccitt_within(&data, &p, h, &mut work).unwrap_err(); + assert!(matches!(refused, FilterError::Spent), "a step short: {refused}"); + assert_eq!(work, 0, "spends all that was left"); + // Nothing left: not read at all, so bad columns aren't reported as bad columns. + let bad = CcittParams { columns: 0, ..p }; + assert!(decode_ccitt_within(&data, &bad, h, &mut 0).is_err_and(|e| matches!(e, FilterError::Spent))); + // No code at all, for the same rows. + let mut work = u64::MAX; + decode_ccitt_within(&[], &p, h, &mut work).unwrap(); + assert_eq!(u64::MAX - work, whole.len() as u64); + // `/Rows` below what is asked for, and no rows. + let mut work = u64::MAX; + decode_ccitt_within(&data, &CcittParams { rows: 3, ..p }, h, &mut work).unwrap(); + assert_eq!(u64::MAX - work, (3 * stride + 4 * data.len()) as u64); + let mut work = u64::MAX; + decode_ccitt_within(&data, &p, 0, &mut work).unwrap(); + assert_eq!(u64::MAX - work, 4 * data.len() as u64); + } + + /// The rows decoded by an independent decoder (`hayro-ccitt`), as 1 for white and 0 for black. + pub(crate) fn decode(data: &[u8], columns: u32, rows: u32) -> Vec> { + struct Rows(Vec>, Vec, usize); + impl hayro_ccitt::Decoder for Rows { + fn push_pixel(&mut self, white: bool) { + self.1.push(white); + } + fn push_pixel_chunk(&mut self, white: bool, chunks: u32) { + self.1.extend(std::iter::repeat_n(white, chunks as usize * 8)); + } + fn next_line(&mut self) { + let mut row = std::mem::take(&mut self.1); + row.truncate(self.2); + self.0.push(row); + } + } + let settings = hayro_ccitt::DecodeSettings { + columns, + rows, + end_of_block: true, + end_of_line: false, + rows_are_byte_aligned: false, + encoding: hayro_ccitt::EncodingMode::Group4, + invert_black: false, + }; + let mut out = Rows(Vec::new(), Vec::new(), columns as usize); + hayro_ccitt::decode(data, &mut out, &mut hayro_ccitt::DecoderContext::new(settings)).unwrap(); + out.0 + } + + /// The rows of packed samples `data`, as 1 for a 1 bit. + pub(crate) fn unpack(data: &[u8], columns: u32, rows: u32) -> Vec> { + let stride = (columns as usize).div_ceil(8); + (0..rows as usize).map(|y| (0..columns as usize).map(|x| data[y * stride + x / 8] & (0x80 >> (x % 8)) != 0).collect()).collect() + } + + #[test] + fn a_page_of_text_like_marks_round_trips_and_shrinks() { + let (w, h) = (1700u32, 220u32); + let stride = (w as usize).div_ceil(8); + let mut data = vec![0xFF; stride * h as usize]; + // Short black strokes in lines, like glyphs on a white page. + for y in (20..h as usize).step_by(40) { + for dy in 0..12 { + for x in (100..1600).step_by(9) { + if (x / 9 + dy) % 5 != 0 { + data[(y + dy) * stride + x / 8] &= !(0x80 >> (x % 8)); + } + } + } + } + let g4 = encode_g4(&data, w, h).unwrap(); + assert_eq!(decode(&g4, w, h), unpack(&data, w, h)); + assert!(g4.len() * 4 < data.len(), "{} bytes from {}", g4.len(), data.len()); + } + + #[test] + fn padding_bits_and_trailing_bytes_are_ignored() { + // 3 columns: the last 5 bits of each byte pad the row; set them to anything. + let data = [0b1010_1111, 0b0101_0000, 0b1110_1010, 0xAB, 0xCD]; + let g4 = encode_g4(&data, 3, 3).unwrap(); + assert_eq!(decode(&g4, 3, 3), [[true, false, true], [false, true, false], [true, true, true]]); + } + + #[test] + fn what_it_cannot_encode_is_an_error() { + assert!(matches!(encode_g4(&[0; 8], 0, 1), Err(FilterError::Unsupported(_)))); + assert!(matches!(encode_g4(&[0; 8], 8, 0), Err(FilterError::Unsupported(_)))); + assert!(matches!(encode_g4(&vec![0; 4097], MAX_G4_COLUMNS + 1, 1), Err(FilterError::Unsupported(_)))); + assert!(matches!(encode_g4(&[0; 3], 16, 2), Err(FilterError::Corrupt { .. })), "two rows of 16 need 4 bytes"); + assert!(matches!(encode_g4(&[], 1, u32::MAX), Err(FilterError::Corrupt { .. }))); + let widest = vec![0x55; (MAX_G4_COLUMNS as usize).div_ceil(8) * 2]; + let g4 = encode_g4(&widest, MAX_G4_COLUMNS, 2).unwrap(); + assert_eq!(decode(&g4, MAX_G4_COLUMNS, 2), unpack(&widest, MAX_G4_COLUMNS, 2)); + } + + /// Rows of `columns` samples built from `runs`: alternating white and black runs one longer + /// than those values (so never empty, even when shrinking makes them all 0), repeated until the + /// rows are full; or, with `noise`, the bytes of `runs` as they are. + fn bitmap(columns: u32, rows: u32, runs: &[u8], noise: bool) -> Vec { + let stride = (columns as usize).div_ceil(8); + let mut data = vec![0u8; stride * rows as usize]; + if runs.is_empty() { + return data; + } + if noise { + data.iter_mut().zip(runs.iter().cycle()).for_each(|(d, r)| *d = *r); + return data; + } + let (mut white, mut left, mut next) = (true, 1 + usize::from(runs[0]), 1); + for y in 0..rows as usize { + for x in 0..columns as usize { + if left == 0 { + white = !white; + left = 1 + usize::from(runs[next % runs.len()]); + next += 1; + } + if white { + data[y * stride + x / 8] |= 0x80 >> (x % 8); + } + left -= 1; + } + } + data + } + + fn g4(columns: u32) -> CcittParams { + CcittParams { k: -1, columns: i64::from(columns), ..CcittParams::default() } + } + + #[test] + fn decoding_gives_the_rows_back() { + // 3 columns, padded with anything: the decoded padding is 0. + let data = [0b1010_1111, 0b0101_0000, 0b1110_1010]; + let coded = encode_g4(&data, 3, 3).unwrap(); + assert_eq!(decode_ccitt(&coded, &g4(3), 3).unwrap(), [0b1010_0000, 0b0100_0000, 0b1110_0000]); + // /BlackIs1 gives black as 1. + let p = CcittParams { black_is_1: true, ..g4(3) }; + assert_eq!(decode_ccitt(&coded, &p, 3).unwrap(), [0b0100_0000, 0b1010_0000, 0b0000_0000]); + // At most /Rows rows, and at most max_rows. + assert_eq!(decode_ccitt(&coded, &CcittParams { rows: 2, ..g4(3) }, 3).unwrap().len(), 2); + assert_eq!(decode_ccitt(&coded, &g4(3), 1).unwrap().len(), 1); + assert_eq!(decode_ccitt(&coded, &CcittParams { rows: 3, ..g4(3) }, 1).unwrap().len(), 1, "max_rows bounds /Rows"); + assert_eq!(decode_ccitt(&coded, &g4(3), 0).unwrap(), [0u8; 0]); + // Damaged data gives the rows before the damage; none here. + assert!(decode_ccitt(&coded[..1], &g4(3), 3).unwrap().len() < 3); + } + + #[test] + fn what_it_cannot_decode_is_an_error() { + for columns in [0, -1, i64::from(MAX_CCITT_COLUMNS) + 1, i64::MAX] { + let p = CcittParams { columns, ..CcittParams::default() }; + assert!(matches!(decode_ccitt(&[0; 8], &p, 4), Err(FilterError::Unsupported(_))), "{columns}"); + } + assert_eq!(CcittParams::default().columns, 1728, "Table 11"); + } + + /// Group 3 data: rows of run lengths below 64, white first, coded one-dimensionally with T.4's + /// terminating codes (ITU-T T.4 Tables 2 and 3). Each row starts with an end-of-line code with + /// `eol`, then with `tag` a 1 bit (the next row one-dimensional, as `k` above 0 has it). Fill + /// bits (0s) go where `fill` says. + fn group_3(rows: &[Vec], eol: bool, tag: bool, fill: Fill) -> Vec { + // The codes for runs 0 to 63, in order. + const WHITE: &str = concat!( + "00110101 000111 0111 1000 1011 1100 1110 1111 10011 10100 00111 01000 001000 000011 110100 110101 101010 101011 ", + "0100111 0001100 0001000 0010111 0000011 0000100 0101000 0101011 0010011 0100100 0011000 00000010 00000011 00011010 ", + "00011011 00010010 00010011 00010100 00010101 00010110 00010111 00101000 00101001 00101010 00101011 00101100 00101101 ", + "00000100 00000101 00001010 00001011 01010010 01010011 01010100 01010101 00100100 00100101 01011000 01011001 01011010 ", + "01011011 01001010 01001011 00110010 00110011 00110100", + ); + const BLACK: &str = concat!( + "0000110111 010 11 10 011 0011 0010 00011 000101 000100 0000100 0000101 0000111 00000100 00000111 000011000 0000010111 ", + "0000011000 0000001000 00001100111 00001101000 00001101100 00000110111 00000101000 00000010111 00000011000 ", + "000011001010 000011001011 000011001100 000011001101 000001101000 000001101001 000001101010 000001101011 000011010010 ", + "000011010011 000011010100 000011010101 000011010110 000011010111 000001101100 000001101101 000011011010 000011011011 ", + "000001010100 000001010101 000001010110 000001010111 000001100100 000001100101 000001010010 000001010011 000000100100 ", + "000000110111 000000111000 000000100111 000000101000 000001011000 000001011001 000000101011 000000101100 000001011010 ", + "000001100110 000001100111", + ); + let code = |white: bool, run: u32| (if white { WHITE } else { BLACK }).split(' ').nth(run as usize).expect("a run below 64"); + let mut bits = String::new(); + for row in rows { + // The end-of-line code is 12 bits. + let ends_code = if fill == Fill::CodeEnd && eol { 12 } else { 0 }; + while fill != Fill::None && !(bits.len() + ends_code).is_multiple_of(8) { + bits.push('0'); + } + if eol { + bits.push_str("000000000001"); + } + if tag { + bits.push('1'); + } + row.iter().enumerate().for_each(|(i, run)| bits.push_str(code(i % 2 == 0, *run))); + } + bits.as_bytes().chunks(8).map(|c| c.iter().enumerate().fold(0u8, |b, (i, d)| b | (u8::from(*d == b'1') << (7 - i)))).collect() + } + + /// Where [`group_3`] puts fill bits: none; so each row, its end-of-line code first, starts on a + /// byte; or so each end-of-line code ends on one (T.4's fill, as TIFF writes it). + #[derive(Clone, Copy, Debug, PartialEq)] + enum Fill { + None, + RowStart, + CodeEnd, + } + + /// `rows` text-like rows of `columns` samples as runs, white first, from `seed`: long white runs + /// and short black ones; a row may start black. + fn text_like(seed: u64, columns: u32, rows: u32) -> Vec> { + let mut seed = seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1; + let mut random = |n: u64| { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + (seed % n) as u32 + }; + (0..rows) + .map(|_| { + let (mut runs, mut left) = (Vec::new(), columns); + while left > 0 { + let run = match runs.len() % 2 { + 0 if runs.is_empty() => random(31), + 0 => 1 + random(30), + _ => 1 + random(5), + }; + runs.push(run.min(left)); + left -= run.min(left); + } + runs + }) + .collect() + } + + /// Rows of runs, white first, packed as `decode_ccitt` gives them (white 1). + fn pack(runs: &[Vec], columns: u32) -> Vec { + let stride = (columns as usize).div_ceil(8); + let mut out = vec![0u8; stride * runs.len()]; + for (y, row) in runs.iter().enumerate() { + let mut x = 0; + for (i, run) in row.iter().enumerate() { + if i % 2 == 0 { + (x..x + *run as usize).for_each(|x| out[y * stride + x / 8] |= 0x80 >> (x % 8)); + } + x += *run as usize; + } + } + out + } + + /// Group 3 rows on bytes in each layout, over many pictures, as how a misreading comes out + /// depends on the picture: no codes (as TIFF's Modified Huffman has it), and `end_of_line` + /// without them; codes, each starting on a byte or ending on one, `end_of_line` or not. Each + /// decodes to its picture, and a byte short to fewer rows, not to other rows read another way. + /// Group 4 rows on bytes keep their aligned read, `end_of_line` or not. + #[test] + fn group_3_rows_on_bytes_read_by_the_codes_in_the_data() { + for seed in 0..32u64 { + let (columns, rows) = (8 + (seed as u32 * 37) % 200, 2 + seed as u32 % 20); + let runs = text_like(seed, columns, rows); + let picture = pack(&runs, columns); + for k in [0, 1] { + for (codes, fill, end_of_line) in [ + (false, Fill::RowStart, false), + (false, Fill::RowStart, true), + (true, Fill::RowStart, false), + (true, Fill::RowStart, true), + (true, Fill::CodeEnd, false), + (true, Fill::CodeEnd, true), + ] { + let p = CcittParams { k, columns: i64::from(columns), end_of_line, encoded_byte_align: true, ..CcittParams::default() }; + let mut data = group_3(&runs, codes, k > 0, fill); + let case = format!("seed {seed}, {columns} × {rows}, k {k}, codes {codes}, fill {fill:?}, end_of_line {end_of_line}"); + assert_eq!(decode_ccitt(&data, &p, rows).unwrap(), picture, "{case}"); + data.pop(); + assert!(decode_ccitt(&data, &p, rows).unwrap().len() < picture.len(), "{case}, a byte short"); + } + } + } + // White rows, each the one code 1 (vertical, no change) and its fill. + for end_of_line in [false, true] { + let p = CcittParams { k: -1, columns: 64, end_of_line, encoded_byte_align: true, ..CcittParams::default() }; + assert_eq!(decode_ccitt(&[0x80; 16], &p, 16).unwrap(), [0xFF; 8 * 16], "Group 4, end_of_line {end_of_line}"); + } + } + + proptest::proptest! { + /// Any bitmap, of noise or of runs, decodes to its own bits, through an independent decoder + /// and through `decode_ccitt`. + #[test] + fn bitmaps_round_trip( + columns in 1u32..400, + rows in 1u32..24, + runs in proptest::collection::vec(0u8..=255, 1..48), + noise in proptest::bool::ANY, + ) { + let data = bitmap(columns, rows, &runs, noise); + let coded = encode_g4(&data, columns, rows).unwrap(); + proptest::prop_assert_eq!(decode(&coded, columns, rows), unpack(&data, columns, rows)); + let decoded = decode_ccitt(&coded, &g4(columns), rows).unwrap(); + proptest::prop_assert_eq!(unpack(&decoded, columns, rows), unpack(&data, columns, rows)); + } + + /// Whatever it is given, it returns. + #[test] + fn any_input_returns( + data in proptest::collection::vec(0u8..=255, 0..600), + columns in 0u32..40_000, + rows in 0u32..80, + k in -2i64..3, + end_of_line in proptest::bool::ANY, + encoded_byte_align in proptest::bool::ANY, + ) { + let _ = encode_g4(&data, columns, rows); + let p = CcittParams { k, columns: i64::from(columns), end_of_line, encoded_byte_align, ..CcittParams::default() }; + if let Ok(out) = decode_ccitt(&data, &p, rows) { + proptest::prop_assert!(out.len() <= (columns as usize).div_ceil(8) * rows as usize); + } + } + } +} diff --git a/crates/filters/src/flate.rs b/crates/filters/src/flate.rs index dac0851e07..1c38f72a53 100644 --- a/crates/filters/src/flate.rs +++ b/crates/filters/src/flate.rs @@ -28,8 +28,9 @@ pub(crate) fn decode(data: &[u8], max: usize) -> Step { // A header that only looked valid: retry the whole input as raw deflate. Err(f) if f.partial.is_empty() && matches!(f.error, crate::FilterError::Corrupt { .. }) => match inflate_raw(data, max) { // Keep the original corruption diagnostic only if the retry is corrupt - // too. A limit error must remain fatal even for tolerant decoding. - Err(retry) if matches!(retry.error, crate::FilterError::Corrupt { .. }) => Err(f), + // too. A limit error must remain fatal even for tolerant decoding. What the + // retry decoded isn't kept, but a caller charging for work has to hear of it. + Err(retry) if matches!(retry.error, crate::FilterError::Corrupt { .. }) => Err(Failure { discarded: retry.partial.len(), ..f }), retry => retry, }, r => r, diff --git a/crates/filters/src/jbig2.rs b/crates/filters/src/jbig2.rs new file mode 100644 index 0000000000..1b705b0c72 --- /dev/null +++ b/crates/filters/src/jbig2.rs @@ -0,0 +1,371 @@ +//! JBIG2 encoding (ITU-T T.88 | ISO/IEC 14492), for `JBIG2Decode` (ISO 32000-2 §7.4.7), lossless: +//! a generic region, template 0 with its nominal adaptive pixels, typical prediction on, MQ +//! arithmetic coding ([`crate::mq`]); or the image's marks as symbols ([`symbols`]). Written from +//! the standard's 6.2.5 (coding a generic region), 7.2 (segment headers), 7.4.1, 7.4.6 and 7.4.8 +//! (the segments) and D.3 (the embedded organisation PDF uses). Decoding is in [`decode`]. + +mod decode; +mod symbols; + +pub use decode::{MAX_JBIG2_PIXELS, decode_jbig2_within}; + +use crate::FilterError; +use crate::mq::{Context, MqEncoder}; + +/// The widest row [`encode_jbig2_generic`] encodes. JBIG2 allows 32-bit widths; this keeps a +/// row's buffers small and its positions in range. +pub const MAX_JBIG2_COLUMNS: u32 = 1 << 20; + +/// Template 0's pixels (Figure 3), as (x, y) offsets from the pixel being coded, each a bit of the +/// context in this order: the four on the current row, the five fixed ones on the row above, the +/// three on the row above that, then the adaptive pixels A1–A4 at their nominal places (Table 5). +const TEMPLATE: [(i32, i32); 16] = [ + (-1, 0), + (-2, 0), + (-3, 0), + (-4, 0), + (-2, -1), + (-1, -1), + (0, -1), + (1, -1), + (2, -1), + (-1, -2), + (0, -2), + (1, -2), + (3, -1), + (-3, -1), + (2, -2), + (-2, -2), +]; + +/// The context that codes SLTP when template 0 is used (Figure 8): those pixel values, in +/// [`TEMPLATE`]'s order. Figure 8 reads, by row from x = -4: `0 1 0 1` on the current row, +/// `0 1 1 0 0 1 0` (x = -3 to 3) above it, `1 0 0 1 1` (x = -2 to 2) above that. +const SLTP: [u8; 16] = [1, 0, 1, 0, 1, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 1]; + +/// Columns of padding either side of a row, so every template pixel of a row's pixels is in it. +const PAD: usize = 4; + +/// `rows` rows of `columns` 1-bit PDF samples as lossless JBIG2, the smaller of two codings: +/// [`encode_jbig2_generic`]'s, or one that stores each distinct mark of the image once, as a +/// symbol, and places its copies (see [`symbols`]). Either way the stream is in the embedded +/// organisation and decodes to the same bits without `/JBIG2Globals`. +/// +/// Errors: as [`encode_jbig2_generic`]. +pub fn encode_jbig2(data: &[u8], columns: u32, rows: u32) -> Result, FilterError> { + let generic = encode_jbig2_generic(data, columns, rows)?; + // `encode_jbig2_generic` checked that the rows are there. + let stride = (columns as usize).div_ceil(8); + let samples = usize::try_from(rows).ok().and_then(|r| r.checked_mul(stride)).and_then(|n| data.get(..n)); + Ok(match samples.and_then(|s| symbols::encode(s, columns, rows)) { + Some(coded) if coded.len() < generic.len() => coded, + _ => generic, + }) +} + +/// `rows` rows of `columns` 1-bit PDF samples (most significant bit first, each row starting on +/// a new byte), as a JBIG2 stream in the embedded organisation: a page information segment and an +/// immediate lossless generic region segment, which `JBIG2Decode` decodes to the same bits +/// without `/JBIG2Globals`. +/// +/// A 0 sample is coded as a JBIG2 1 (black), since `JBIG2Decode` gives black as 0, as a grey image's +/// samples have it. Bytes after the last row are ignored, as are the bits that pad a row to a whole +/// byte. +/// +/// Errors: no rows or columns, or rows wider than [`MAX_JBIG2_COLUMNS`] +/// ([`FilterError::Unsupported`]); fewer bytes than the rows need ([`FilterError::Corrupt`]). +pub fn encode_jbig2_generic(data: &[u8], columns: u32, rows: u32) -> Result, FilterError> { + if columns == 0 || rows == 0 || columns > MAX_JBIG2_COLUMNS { + return Err(FilterError::Unsupported(format!("JBIG2 encoding of {columns} × {rows} samples"))); + } + let width = columns as usize; + let stride = width.div_ceil(8); + let samples = usize::try_from(rows).ok().and_then(|r| r.checked_mul(stride)).and_then(|n| data.get(..n)).ok_or_else(|| FilterError::Corrupt { + filter: "JBIG2Decode", + detail: format!("{} bytes for {rows} rows of {columns} samples", data.len()), + })?; + let mut contexts: Vec = vec![0; 1 << 16]; + let mut mq = MqEncoder::new(); + let height = samples.len() / stride; + code_generic(&mut mq, &mut contexts, width, height, true, |y, row| { + let packed = samples.get(y * stride..).unwrap_or_default(); + for (x, pixel) in row.iter_mut().enumerate() { + let sample = packed.get(x / 8).is_some_and(|b| b & (0x80 >> (x % 8)) != 0); + *pixel = u8::from(!sample); + } + }); + let region = mq.finish(); + let mut out = Vec::with_capacity(region.len() + 64); + segment(&mut out, 0, 48, &[], false, &page_information(columns, rows)); + segment(&mut out, 1, 39, &[], false, &generic_region(columns, rows, 0, 0, ®ion)); + Ok(out) +} + +/// A page information segment's data (7.4.8): the page's size, resolution unknown, eventually +/// lossless, default pixel 0 (white), OR combination, not striped. +fn page_information(columns: u32, rows: u32) -> Vec { + let mut page = Vec::with_capacity(19); + page.extend_from_slice(&columns.to_be_bytes()); + page.extend_from_slice(&rows.to_be_bytes()); + page.extend_from_slice(&[0; 8]); + page.push(0x01); + page.extend_from_slice(&[0, 0]); + page +} + +/// A region segment information field (7.4.1): a `width` × `height` region at (`x`, `y`) on the +/// page, combined with it by OR. +fn region_information(out: &mut Vec, width: u32, height: u32, x: u32, y: u32) { + for v in [width, height, x, y] { + out.extend_from_slice(&v.to_be_bytes()); + } + out.push(0); +} + +/// An immediate lossless generic region segment's data (7.4.6): its region information, flags +/// (MMR 0, template 0, TPGDON 1), the AT pixels at their nominal places, then `coded`. +fn generic_region(width: u32, height: u32, x: u32, y: u32, coded: &[u8]) -> Vec { + let mut data = Vec::with_capacity(26 + coded.len()); + region_information(&mut data, width, height, x, y); + data.push(0x08); + at_pixels(&mut data); + data.extend_from_slice(coded); + data +} + +/// Template 0's four AT pixels at their nominal places, as AT flags (7.4.6.3, 7.4.2.1.2). +fn at_pixels(out: &mut Vec) { + for (x, y) in &TEMPLATE[12..] { + out.push(*x as i8 as u8); + out.push(*y as i8 as u8); + } +} + +/// A segment (7.2): its header (number, type, the segments it refers to, page 1, data length), +/// then its data. `retained`: a later segment refers to this one. `refers_to` holds each +/// referred-to segment's number and whether a segment after this one refers to it too (its +/// retain bit, 7.2.4). Segment numbers here stay below 256, so each referred-to one takes a byte +/// (7.2.5); a segment refers to at most four, so their count and retain bits take one byte too. +fn segment(out: &mut Vec, number: u32, kind: u8, refers_to: &[(u8, bool)], retained: bool, data: &[u8]) { + out.extend_from_slice(&number.to_be_bytes()); + out.push(kind); + // At most four, so the count fits its three bits. + let refers_to = refers_to.get(..refers_to.len().min(4)).unwrap_or_default(); + let kept = refers_to.iter().enumerate().fold(0u8, |bits, (k, (_, kept))| bits | u8::from(*kept) << (k + 1)); + out.push((refers_to.len() as u8) << 5 | kept | u8::from(retained)); + out.extend(refers_to.iter().map(|(n, _)| *n)); + out.push(1); + // A segment's data never comes near 4 GiB here: a row is at most 1 MiB wide. + out.extend_from_slice(&u32::try_from(data.len()).unwrap_or(u32::MAX).to_be_bytes()); + out.extend_from_slice(data); +} + +/// Codes a `width` × `height` bitmap as a generic region (6.2.5.7) with template 0 and the nominal +/// AT pixels, its decisions in `contexts` (2^16 of them), with typical prediction when `typical` +/// (TPGDON). `fill(y, row)` sets row `y`'s `width` pixels, 1 for black. Pixels outside the bitmap +/// count as 0 (white), as the decoder takes them. +fn code_generic(mq: &mut MqEncoder, contexts: &mut [Context], width: usize, height: usize, typical: bool, mut fill: impl FnMut(usize, &mut [u8])) { + let sltp = SLTP.iter().enumerate().fold(0usize, |cx, (k, v)| cx | usize::from(*v) << k); + // The two rows above the current one and the current one, as JBIG2 pixels (1 black), padded. + let blank = vec![0u8; width + 2 * PAD]; + let (mut above2, mut above1) = (blank.clone(), blank.clone()); + let mut row = blank; + let mut ltp = false; + for y in 0..height { + if let Some(pixels) = row.get_mut(PAD..PAD + width) { + fill(y, pixels); + } + if typical { + // Typical prediction (6.2.5.5): a row the same as the one above is coded as one decision. + let same = row == above1; + if let Some(cx) = contexts.get_mut(sltp) { + mq.encode(cx, same != ltp); + } + ltp = same; + } + if !ltp { + for x in 0..width { + let at = |dx: i32, dy: i32| { + let line = match dy { + 0 => &row, + -1 => &above1, + _ => &above2, + }; + let i = (PAD + x).checked_add_signed(dx as isize); + i.and_then(|i| line.get(i)).copied().unwrap_or(0) + }; + let cx = TEMPLATE.iter().enumerate().fold(0usize, |cx, (k, (dx, dy))| cx | usize::from(at(*dx, *dy)) << k); + if let Some(state) = contexts.get_mut(cx) { + mq.encode(state, row.get(PAD + x) == Some(&1)); + } + } + } + std::mem::swap(&mut above2, &mut above1); + std::mem::swap(&mut above1, &mut row); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The rows an independent decoder (`hayro-jbig2`) reads from `data`, as PDF samples: true + /// for white (a JBIG2 0). + pub(super) fn decode(data: &[u8]) -> Vec> { + struct Rows(Vec>, Vec, usize); + impl hayro_jbig2::Decoder for Rows { + fn push_pixel(&mut self, black: bool) { + self.1.push(!black); + } + fn push_pixel_chunk(&mut self, black: bool, chunks: u32) { + self.1.extend(std::iter::repeat_n(!black, chunks as usize * 8)); + } + fn next_line(&mut self) { + let mut row = std::mem::take(&mut self.1); + row.truncate(self.2); + self.0.push(row); + } + } + let image = hayro_jbig2::Image::new_embedded(data, None).unwrap(); + let mut rows = Rows(Vec::new(), Vec::new(), image.width() as usize); + image.decode(&mut rows).unwrap(); + rows.0 + } + + /// The rows of packed samples `data`, true for a 1 bit. + pub(super) fn unpack(data: &[u8], columns: u32, rows: u32) -> Vec> { + let stride = (columns as usize).div_ceil(8); + (0..rows as usize).map(|y| (0..columns as usize).map(|x| data[y * stride + x / 8] & (0x80 >> (x % 8)) != 0).collect()).collect() + } + + /// Rows of `columns` samples from `runs`: alternating white and black runs one longer than + /// those values (never empty, even when shrinking makes them all 0), until the rows are full; + /// or, with `noise`, the bytes of `runs` as they are. + fn bitmap(columns: u32, rows: u32, runs: &[u8], noise: bool) -> Vec { + let stride = (columns as usize).div_ceil(8); + let mut data = vec![0u8; stride * rows as usize]; + if runs.is_empty() { + return data; + } + if noise { + data.iter_mut().zip(runs.iter().cycle()).for_each(|(d, r)| *d = *r); + return data; + } + let (mut white, mut left, mut next) = (true, 1 + usize::from(runs[0]), 1); + for y in 0..rows as usize { + for x in 0..columns as usize { + if left == 0 { + white = !white; + left = 1 + usize::from(runs[next % runs.len()]); + next += 1; + } + if white { + data[y * stride + x / 8] |= 0x80 >> (x % 8); + } + left -= 1; + } + } + data + } + + #[test] + fn a_page_of_text_like_marks_round_trips_and_shrinks() { + let (w, h) = (1700u32, 220u32); + let stride = (w as usize).div_ceil(8); + let mut data = vec![0xFF; stride * h as usize]; + for y in (20..h as usize).step_by(40) { + for dy in 0..12 { + for x in (100..1600).step_by(9) { + if (x / 9 + dy) % 5 != 0 { + data[(y + dy) * stride + x / 8] &= !(0x80 >> (x % 8)); + } + } + } + } + let jbig2 = encode_jbig2_generic(&data, w, h).unwrap(); + assert_eq!(decode(&jbig2), unpack(&data, w, h)); + let g4 = crate::encode_g4(&data, w, h).unwrap(); + assert!(jbig2.len() < g4.len(), "JBIG2 {} bytes, Group 4 {}", jbig2.len(), g4.len()); + } + + #[test] + fn edges_padding_and_typical_rows_round_trip() { + // 3 columns: the last 5 bits of each byte pad the row; set them to anything. + let data = [0b1010_1111, 0b0101_0000, 0b1110_1010, 0xAB, 0xCD]; + assert_eq!(decode(&encode_jbig2_generic(&data, 3, 3).unwrap()), [[true, false, true], [false, true, false], [true, true, true]]); + for (w, h, fill) in [(1, 1, 0x00), (1, 1, 0xFF), (13, 9, 0x00), (13, 9, 0xFF), (64, 40, 0x5A)] { + let data = vec![fill; (w as usize).div_ceil(8) * h as usize]; + assert_eq!(decode(&encode_jbig2_generic(&data, w, h).unwrap()), unpack(&data, w, h), "{w} × {h} of {fill:#04x}"); + } + } + + /// Packed samples from JBIG2 pixels (1 black): a 0 sample for each 1. + pub(super) fn samples(pixels: &[Vec]) -> Vec { + let width = pixels.first().map_or(0, Vec::len); + let stride = width.div_ceil(8); + let mut data = vec![0xFF; stride * pixels.len()]; + for (y, row) in pixels.iter().enumerate() { + for (x, p) in row.iter().enumerate() { + if *p == 1 { + data[y * stride + x / 8] &= !(0x80 >> (x % 8)); + } + } + } + data + } + + /// A row the same as the one above is typical (6.2.5.5): one decision, not a row of pixels. + #[test] + fn a_repeated_row_is_one_decision() { + let row: Vec = (0..800u32).map(|x| u8::from((x * 7919 + x / 3) % 5 < 2)).collect(); + let pixels = vec![row; 1000]; + let data = samples(&pixels); + crate::mq::DECISIONS.with(|n| n.set(0)); + let jbig2 = encode_jbig2_generic(&data, 800, 1000).unwrap(); + // The first row's 800 pixels, and one typical-row decision for each of the 1000 rows. + assert_eq!(crate::mq::DECISIONS.with(|n| n.get()), 800 + 1000); + assert_eq!(decode(&jbig2), unpack(&data, 800, 1000)); + } + + /// SLTP is coded in the context Figure 8 shows, which ordinary pixels can share: an image + /// tiled with that very neighbourhood decodes only if both coders use the same context. + #[test] + fn the_typical_row_flag_shares_figure_8s_context() { + // Figure 8, around a pixel at x = 4 of an 8-column tile: two rows up x = 2..6, one row up + // x = 1..7, the pixel's own row x = 0..3. + let tile = [[0, 0, 1, 0, 0, 1, 1, 0], [0, 0, 1, 1, 0, 0, 1, 0], [0, 1, 0, 1, 1, 0, 0, 0]]; + let pixels: Vec> = (0..90).map(|y| (0..400).map(|x| tile[y % 3][x % 8]).collect()).collect(); + let data = samples(&pixels); + assert_eq!(decode(&encode_jbig2_generic(&data, 400, 90).unwrap()), unpack(&data, 400, 90)); + } + + #[test] + fn what_it_cannot_encode_is_an_error() { + assert!(matches!(encode_jbig2_generic(&[0; 8], 0, 1), Err(FilterError::Unsupported(_)))); + assert!(matches!(encode_jbig2_generic(&[0; 8], 8, 0), Err(FilterError::Unsupported(_)))); + assert!(matches!(encode_jbig2_generic(&[0; 8], MAX_JBIG2_COLUMNS + 1, 1), Err(FilterError::Unsupported(_)))); + assert!(matches!(encode_jbig2_generic(&[0; 3], 16, 2), Err(FilterError::Corrupt { .. })), "two rows of 16 need 4 bytes"); + assert!(matches!(encode_jbig2_generic(&[], 1, u32::MAX), Err(FilterError::Corrupt { .. }))); + } + + proptest::proptest! { + /// Any bitmap, of noise or of runs, decodes to its own bits through an independent decoder. + #[test] + fn bitmaps_round_trip( + columns in 1u32..300, + rows in 1u32..24, + runs in proptest::collection::vec(0u8..=255, 1..48), + noise in proptest::bool::ANY, + ) { + let data = bitmap(columns, rows, &runs, noise); + let jbig2 = encode_jbig2_generic(&data, columns, rows).unwrap(); + proptest::prop_assert_eq!(decode(&jbig2), unpack(&data, columns, rows)); + } + + /// Whatever it is given, it returns. + #[test] + fn any_input_returns(data in proptest::collection::vec(0u8..=255, 0..600), columns in 0u32..5000, rows in 0u32..80) { + let _ = encode_jbig2_generic(&data, columns, rows); + } + } +} diff --git a/crates/filters/src/jbig2/decode.rs b/crates/filters/src/jbig2/decode.rs new file mode 100644 index 0000000000..a018e75937 --- /dev/null +++ b/crates/filters/src/jbig2/decode.rs @@ -0,0 +1,216 @@ +//! JBIG2 decoding for `JBIG2Decode` (ISO 32000-2 §7.4.7), through `hayro-jbig2`. + +use crate::FilterError; +use crate::ccitt::Rows; + +/// The largest JBIG2 page [`decode_jbig2_within`] decodes, in pixels. The decoder decodes a page whole, +/// and takes pages of up to about this many (20,000 × 10,000 decodes). +pub const MAX_JBIG2_PIXELS: u64 = 1 << 28; + +/// A `JBIG2Decode` stream's `data`, with what its `/JBIG2Globals` stream decodes to, decoded as the +/// filter gives it: `rows` rows of `columns` 1-bit samples, most significant bit first, each +/// starting on a new byte, with black as 0 (JBIG2 codes black as 1). +/// +/// The JBIG2 page has to be the image's size, `columns` × `rows`: PdfCraft's renderer draws the +/// whole page into the image, so the rows of a page of another height wouldn't be the image's. The +/// decoder decodes the page whole, so it is checked before it is decoded, and has to fit in `max` +/// bytes as such rows and have at most [`MAX_JBIG2_PIXELS`]. Regions inside it are bounded by the +/// decoder alone: a region may claim more pixels than its page, and is decoded before it is cut to +/// the page. With no rows asked for, nothing is decoded. +/// +/// Errors: `data` or `globals` that don't decode, or a page of another size +/// ([`FilterError::Corrupt`]); a page past `max` ([`FilterError::LimitExceeded`]) or +/// [`MAX_JBIG2_PIXELS`] ([`FilterError::Unsupported`], or `Corrupt` from the decoder, which has the +/// same cap and can refuse the page first). +/// +/// Decoding spends from `work`: what decoding may still cost across a run of images, in the +/// decoder's count of steps. There is no way in without it, so a run of images can't decode them +/// without sharing an allowance. Reading `data` and `globals` is charged first +/// ([`hayro_jbig2::charge_reading`]), so an image that turns out not to decode has paid for that, +/// then what decoding takes ([`hayro_jbig2::Image::decode_within`]). An image takes at most what +/// one decode may on top of its reading and its page, whatever `work` allows, and one that `work` +/// can't pay for spends all of it. With nothing left an image isn't read at all, so the images after +/// that one cost nothing but this check, however large their globals. +pub fn decode_jbig2_within(data: &[u8], globals: Option<&[u8]>, columns: u32, rows: u32, max: usize, work: &mut u64) -> Result, FilterError> { + if rows == 0 { + return Ok(Vec::new()); + } + if *work == 0 { + return Err(FilterError::Spent); + } + hayro_jbig2::charge_reading(work, data.len().saturating_add(globals.map_or(0, <[u8]>::len))).map_err(|_| FilterError::Spent)?; + let corrupt = |detail: String| FilterError::Corrupt { filter: "JBIG2Decode", detail }; + let image = hayro_jbig2::Image::new_embedded(data, globals).map_err(|e| corrupt(e.to_string()))?; + let (width, height) = (image.width(), image.height()); + if (width, height) != (columns, rows) { + return Err(corrupt(format!("a page of {width} × {height} pixels for an image of {columns} × {rows}"))); + } + if u64::from(width) * u64::from(height) > MAX_JBIG2_PIXELS { + return Err(FilterError::Unsupported(format!("JBIG2 pages of {width} × {height} pixels"))); + } + // u32 to usize: wasm32 and up have at least 32-bit usize. + let (width, height) = (width as usize, height as usize); + if width.div_ceil(8).checked_mul(height).is_none_or(|bytes| bytes > max) { + return Err(FilterError::LimitExceeded(max)); + } + let mut out = Rows::new(width, height, false); + // Stopped by what was left, it isn't a damaged image, whatever the decoder calls it. + image.decode_within(&mut out, work).map_err(|e| if *work == 0 { FilterError::Spent } else { corrupt(e.to_string()) })?; + Ok(out.out) +} + +/// JBIG2 codes black as 1, which `JBIG2Decode` gives as 0. +impl hayro_jbig2::Decoder for Rows { + fn push_pixel(&mut self, black: bool) { + self.push(!black, 1); + } + fn push_pixel_chunk(&mut self, black: bool, chunks: u32) { + self.push(!black, (chunks as usize).saturating_mul(8)); + } + fn next_line(&mut self) { + self.end_row(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{encode_jbig2, encode_jbig2_generic}; + + /// [`decode_jbig2_within`] with no limit on the work. + fn decode_jbig2(data: &[u8], globals: Option<&[u8]>, columns: u32, rows: u32, max: usize) -> Result, FilterError> { + let mut unbounded = u64::MAX; + decode_jbig2_within(data, globals, columns, rows, max, &mut unbounded) + } + + /// `rows` rows of `columns` samples of noise, the bits that pad each row to a byte 0. + fn noise(columns: u32, rows: u32) -> Vec { + let stride = (columns as usize).div_ceil(8); + let mut seed = 0x2545_F491_4F6C_DD1Du64; + let mut data: Vec = (0..stride * rows as usize) + .map(|_| { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + seed as u8 + }) + .collect(); + let pad = (stride * 8 - columns as usize) as u32; + data.chunks_mut(stride).for_each(|row| *row.last_mut().unwrap() &= 0xFFu8.checked_shl(pad).unwrap_or(0)); + data + } + + /// The same stream with its page information (the first segment's data, after its 11-byte + /// header: width, then height) claiming a page of `width` × `height`. + fn page_of(data: &[u8], width: u32, height: u32) -> Vec { + let mut data = data.to_vec(); + data[11..15].copy_from_slice(&width.to_be_bytes()); + data[15..19].copy_from_slice(&height.to_be_bytes()); + data + } + + /// Either coding decodes to its samples, black as 0, the bits that pad each row 0: a generic + /// region of noise, and symbols for letters repeated across the page. With no rows asked for, + /// nothing is decoded. + #[test] + fn both_codings_decode_to_their_samples() { + let (w, h) = (203u32, 41u32); + let bits = noise(w, h); + let generic = encode_jbig2_generic(&bits, w, h).unwrap(); + assert_eq!(decode_jbig2(&generic, None, w, h, usize::MAX).unwrap(), bits); + assert_eq!(decode_jbig2(b"not JBIG2", None, w, 0, usize::MAX).unwrap(), b"", "no rows asked for"); + + // Four lines of three letters, black on white. + const LETTERS: [[&str; 7]; 3] = [ + ["#####", "#....", "####.", "#....", "#....", "#....", "#####"], + [".###.", "#...#", "#....", "#....", "#....", "#...#", ".###."], + ["#...#", "#...#", "#####", "#...#", "#...#", "#...#", "#...#"], + ]; + let (w, h) = (400u32, 40u32); + let stride = (w as usize).div_ceil(8); + let mut marks = vec![0xFFu8; stride * h as usize]; + for (line, top) in [2usize, 12, 22, 32].into_iter().enumerate() { + for (k, x) in (3..w as usize - 6).step_by(7).enumerate() { + for (dy, row) in LETTERS[(k + line) % 3].iter().enumerate() { + for (dx, _) in row.bytes().enumerate().filter(|(_, c)| *c == b'#') { + marks[(top + dy) * stride + (x + dx) / 8] &= !(0x80 >> ((x + dx) % 8)); + } + } + } + } + let symbols = encode_jbig2(&marks, w, h).unwrap(); + assert!(symbols.len() < encode_jbig2_generic(&marks, w, h).unwrap().len(), "symbol coded"); + assert_eq!(decode_jbig2(&symbols, None, w, h, usize::MAX).unwrap(), marks); + } + + /// What a page costs is the same however much a run has left. Given exactly that it decodes, + /// and less stops it: spending all that's left, and what comes after isn't read. A page that + /// fails still spends what it took. + #[test] + fn a_run_of_images_spends_its_allowance_down_to_the_step() { + let (w, h) = (203u32, 41u32); + let data = encode_jbig2_generic(&noise(w, h), w, h).unwrap(); + let whole = decode_jbig2(&data, None, w, h, usize::MAX).unwrap(); + let mut work = u64::MAX; + assert_eq!(decode_jbig2_within(&data, None, w, h, usize::MAX, &mut work).unwrap(), whole); + let cost = u64::MAX - work; + assert!(cost >= u64::from(w) * u64::from(h), "a pixel at a time: {cost}"); + let mut work = cost; + assert_eq!(decode_jbig2_within(&data, None, w, h, usize::MAX, &mut work).unwrap(), whole, "exactly enough"); + assert_eq!(work, 0); + let mut work = cost + 7; + decode_jbig2_within(&data, None, w, h, usize::MAX, &mut work).unwrap(); + assert_eq!(work, 7, "only what it took"); + let mut work = cost - 1; + assert!(matches!(decode_jbig2_within(&data, None, w, h, usize::MAX, &mut work), Err(FilterError::Spent)), "a step short"); + assert_eq!(work, 0, "spends all that was left"); + // Nothing left: not read at all, so this garbage isn't reported as garbage. + assert!(matches!(decode_jbig2_within(b"garbage", None, w, h, usize::MAX, &mut work), Err(FilterError::Spent))); + // Too little even for its page. + let mut work = 3; + assert!(decode_jbig2_within(&data, None, w, h, usize::MAX, &mut work).is_err()); + assert_eq!(work, 0); + // A region that can't be read after the one that can: the page fails having spent the + // first, and the segment it fails on, and that isn't being out of allowance. + let bad_region = [0, 0, 0, 9, 38, 0, 1, 0, 0, 0, 1, 0xFF]; + let mut work = u64::MAX; + assert!(decode_jbig2_within(&[&data[..], &bad_region].concat(), None, w, h, usize::MAX, &mut work).is_err()); + assert_eq!(u64::MAX - work, cost + 1 + 4 * bad_region.len() as u64, "the first region, a segment more, and reading it"); + // Not a stream at all: it pays for being read, and globals count with it. + let mut work = u64::MAX; + assert!(matches!(decode_jbig2_within(b"garbage", Some(b"globals"), w, h, usize::MAX, &mut work), Err(FilterError::Corrupt { .. }))); + assert_eq!(u64::MAX - work, 4 * 14); + } + + /// The page has to be the image's size, and is bounded before it is decoded, being decoded + /// whole: within `max` as rows of one bit, and within [`MAX_JBIG2_PIXELS`]. Globals are given + /// to the decoder: ones it skips decode, ones that aren't JBIG2 are an error, as is damaged + /// data, which never panics. + #[test] + fn what_it_cannot_decode_is_an_error() { + let (w, h) = (203u32, 41u32); + let data = encode_jbig2_generic(&noise(w, h), w, h).unwrap(); + for (columns, rows) in [(w - 1, h), (w + 1, h), (w, h - 1), (w, h + 1)] { + let e = decode_jbig2(&data, None, columns, rows, usize::MAX).unwrap_err(); + assert!(matches!(e, FilterError::Corrupt { .. }), "{columns} × {rows}: {e}"); + } + let page = 26 * h as usize; + assert!(matches!(decode_jbig2(&data, None, w, h, page - 1), Err(FilterError::LimitExceeded(m)) if m == page - 1), "a byte short"); + assert_eq!(decode_jbig2(&data, None, w, h, page).unwrap().len(), page, "the page within max"); + // 16,384 × 16,384 is 2^28 pixels, which decodes; a row more is past the cap, here or in the + // decoder, which has the same one and can refuse it first. + let large = |rows| decode_jbig2(&page_of(&data, 16_384, rows), None, 16_384, rows, usize::MAX); + assert!(matches!(large(16_385), Err(FilterError::Unsupported(_) | FilterError::Corrupt { .. })), "{:?}", large(16_385)); + assert!(large(16_384).is_ok(), "{:?}", large(16_384).map(|page| page.len())); + + // An extension segment (ISO/IEC 14492 7.4.14), which a decoder skips: its 11-byte header + // gives the length of the data after it. + let skipped = [0, 0, 0, 0, 0x3E, 0, 0, 0, 0, 0, 4, 0, 0, 0, 1]; + let generic = decode_jbig2(&data, None, w, h, usize::MAX).unwrap(); + assert_eq!(decode_jbig2(&data, Some(&skipped), w, h, usize::MAX).unwrap(), generic); + assert!(matches!(decode_jbig2(&data, Some(b"not JBIG2"), w, h, usize::MAX), Err(FilterError::Corrupt { .. }))); + for end in 0..data.len() { + let _ = decode_jbig2(&data[..end], None, w, h, usize::MAX); + } + } +} diff --git a/crates/filters/src/jbig2/symbols.rs b/crates/filters/src/jbig2/symbols.rs new file mode 100644 index 0000000000..e9fed24441 --- /dev/null +++ b/crates/filters/src/jbig2/symbols.rs @@ -0,0 +1,1029 @@ +//! JBIG2 symbol coding, lossless: each distinct mark of an image (a group of black pixels that +//! touch, side by side or at a corner) is stored once, as a symbol in a symbol dictionary, and +//! text regions place a copy of it wherever it occurs. Marks too large to be symbols (rules, +//! frames, pictures) are left to a generic region beside them, and so, in [`encode`]'s second +//! plan, are marks that occur only once. Only exact copies share a symbol, so the image decodes to the +//! same bits. Written from the committee draft's 6.4 (the text region decoding procedure), 6.5 +//! (the symbol dictionary decoding procedure), 7.4.2 and 7.4.3 (their segments) and Annex A (the +//! arithmetic integer coders). + +use std::collections::HashMap; +use std::hash::{DefaultHasher, Hash, Hasher}; +use std::ops::Range; + +use super::{at_pixels, code_generic, generic_region, page_information, region_information, segment}; +use crate::mq::{Context, MqEncoder}; + +/// The longest side of a mark stored as a symbol. A larger one is left to the generic region. +const MAX_SYMBOL_SIDE: u32 = 256; + +/// The most black runs looked at. Past it (halftones, noise) the image is left to generic coding, +/// which suits it better anyway; this bounds the memory the marks take. +const MAX_RUNS: usize = 1 << 20; + +/// The most distinct marks sorted into symbols, and the most pixels their bitmaps may hold +/// together: past either the image is left to generic coding. These bound the memory they take. +const MAX_SYMBOLS: usize = 1 << 16; +const MAX_SYMBOL_PIXELS: usize = 1 << 24; + +/// The most symbols one plan codes. The decoder PdfCraft renders JBIG2 with (`hayro-jbig2`) +/// refuses 32,768 symbols or more, and others may too, so a plan with more isn't made. +const MAX_CODED_SYMBOLS: usize = (1 << 15) - 1; + +/// The most symbol instances one text region places. The decoder PdfCraft renders JBIG2 with +/// refuses a text region of more than 10,000 (a page it then draws blank), so a page with more is +/// placed by several regions, each over a band of rows, all referring to the one dictionary. +const MAX_REGION_INSTANCES: usize = 10_000; + +// Segment numbers stay at most 256, so a referred-to one takes a byte (7.2.5): the page, the +// dictionary, a text region for each MAX_REGION_INSTANCES of at most MAX_RUNS marks, and the +// generic region. +const _: () = assert!(MAX_RUNS.div_ceil(MAX_REGION_INSTANCES) + 3 <= 256); + +/// The most rows symbol coded. With rows at most 2^20 samples wide, every coordinate and +/// difference coded then fits the 32-bit integers Annex A codes. +const MAX_ROWS: u32 = 1 << 24; + +/// The integer coders' ranges (Table A.1): how many 1 bits pick the range, how many bits give the +/// offset into it, and its first value. The last range has no 0 after its prefix. +const RANGES: [(u32, u32, u32); 6] = [(0, 2, 0), (1, 4, 4), (2, 6, 20), (3, 8, 84), (4, 12, 340), (5, 32, 4436)]; + +/// A run of black pixels on row `y`, from `x0` up to but not including `x1`. +#[derive(Clone, Copy, Debug)] +struct Run { + y: u32, + x0: u32, + x1: u32, +} + +/// A mark: its box (`x1` and `y1` just past it) and its runs, as a range of the run order +/// [`marks`] gives. +#[derive(Debug)] +struct Mark { + x0: u32, + x1: u32, + y0: u32, + y1: u32, + runs: Range, +} + +/// A distinct mark's bitmap, a byte a pixel (1 black), and how many marks are exact copies of it. +#[derive(Debug)] +struct Symbol { + width: u32, + height: u32, + pixels: Vec, + uses: u32, +} + +/// An image's marks, sorted out: its black runs, its marks with the run order they index, the +/// distinct marks of at most [`MAX_SYMBOL_SIDE`] a side as symbols, each such mark as (its +/// symbol, the mark), and the marks too large to be symbols. +struct Page { + runs: Vec, + marks: Vec, + order: Vec, + symbols: Vec, + copies: Vec<(u32, u32)>, + large: Vec, +} + +/// A symbol instance (6.4.5): the symbol, and where its bottom-left pixel goes on the page, as S +/// (the column) and T (the row). `width` and `height` are the symbol's. +#[derive(Clone, Copy, Debug)] +struct Instance { + id: u32, + s: u32, + t: u32, + width: u32, + height: u32, +} + +/// `rows` rows of `columns` 1-bit PDF samples (black 0), symbol coded: the smaller of two plans, +/// every mark of at most [`MAX_SYMBOL_SIDE`] a side as a symbol, or only those with a copy. +/// `None` when neither plan has from two to [`MAX_CODED_SYMBOLS`] symbols, or the image is too +/// large or too busy to look at. +pub(super) fn encode(samples: &[u8], columns: u32, rows: u32) -> Option> { + let page = page(samples, columns, rows)?; + [1, 2].into_iter().filter_map(|copies| plan(&page, columns, rows, copies, None)).min_by_key(Vec::len) +} + +/// The image's marks and symbols; `None` past the limits above. +fn page(samples: &[u8], columns: u32, rows: u32) -> Option { + if rows > MAX_ROWS { + return None; + } + let width = usize::try_from(columns).ok()?; + let stride = width.div_ceil(8); + if stride == 0 { + return None; + } + let runs = black_runs(samples, width, stride)?; + let (marks, order) = marks(&runs); + let mut page = Page { runs, marks, order, symbols: Vec::new(), copies: Vec::new(), large: Vec::new() }; + symbols(&mut page)?; + Some(page) +} + +/// The black runs of each row (0 samples), row by row and left to right; `None` past [`MAX_RUNS`]. +fn black_runs(samples: &[u8], width: usize, stride: usize) -> Option> { + let black = |row: &[u8], x: usize| row.get(x / 8).is_some_and(|b| b & (0x80 >> (x % 8)) == 0); + let mut runs = Vec::new(); + for (y, row) in samples.chunks_exact(stride).enumerate() { + let y = u32::try_from(y).ok()?; + let mut x = 0; + while x < width { + // Whole white bytes at once. + if x % 8 == 0 && row.get(x / 8) == Some(&0xFF) { + x += 8; + continue; + } + if !black(row, x) { + x += 1; + continue; + } + let start = x; + while x < width && black(row, x) { + x += 1; + } + if runs.len() == MAX_RUNS { + return None; + } + runs.push(Run { y, x0: u32::try_from(start).ok()?, x1: u32::try_from(x).ok()? }); + } + } + Some(runs) +} + +/// The marks the runs make, in the order their first runs come (so from the top), and the run +/// order: the runs' indexes, mark by mark, each mark's in row order. +fn marks(runs: &[Run]) -> (Vec, Vec) { + // Union–find over the runs; a set's root is its first run, as the larger root always joins + // the smaller. Runs on adjacent rows join when they overlap, counting a corner. + let mut parent: Vec = (0..runs.len()).map(|i| i as u32).collect(); + let (mut above, mut above_at, mut above_y): (&[Run], usize, Option) = (&[], 0, None); + let mut at = 0; + for row in runs.chunk_by(|a, b| a.y == b.y) { + let y = row.first().map(|r| r.y); + if above_y.and_then(|a| a.checked_add(1)) == y { + let mut i = 0; + for (j, b) in row.iter().enumerate() { + while above.get(i).is_some_and(|a| a.x1 < b.x0) { + i += 1; + } + let mut k = i; + while above.get(k).is_some_and(|a| a.x0 <= b.x1) { + union(&mut parent, above_at + k, at + j); + k += 1; + } + } + } + (above, above_at, above_y) = (row, at, y); + at += row.len(); + } + let mut mark_of = vec![0u32; runs.len()]; + let mut marks: Vec = Vec::new(); + let mut counts: Vec = Vec::new(); + for (i, r) in runs.iter().enumerate() { + let root = find_root(&mut parent, i); + let m = if root == i { + marks.push(Mark { x0: r.x0, x1: r.x1, y0: r.y, y1: r.y.saturating_add(1), runs: 0..0 }); + counts.push(0); + marks.len() - 1 + } else { + mark_of.get(root).map_or(0, |m| *m as usize) + }; + if let Some(slot) = mark_of.get_mut(i) { + *slot = m as u32; + } + if let (Some(mark), Some(count)) = (marks.get_mut(m), counts.get_mut(m)) { + mark.x0 = mark.x0.min(r.x0); + mark.x1 = mark.x1.max(r.x1); + mark.y1 = mark.y1.max(r.y.saturating_add(1)); + *count += 1; + } + } + let mut next = 0; + for (mark, count) in marks.iter_mut().zip(&counts) { + mark.runs = next..next + count; + next += count; + } + let mut order = vec![0u32; runs.len()]; + let mut fill: Vec = marks.iter().map(|m| m.runs.start).collect(); + for (i, m) in mark_of.iter().enumerate() { + if let Some(n) = fill.get_mut(*m as usize) { + if let Some(slot) = order.get_mut(*n) { + *slot = i as u32; + } + *n += 1; + } + } + (marks, order) +} + +/// The root of `i`'s set, halving the path to it as it goes. Parents are never above their +/// children, so the walk ends. +fn find_root(parent: &mut [u32], mut i: usize) -> usize { + loop { + let Some(&p) = parent.get(i) else { return i }; + let p = p as usize; + if p == i { + return i; + } + let grandparent = parent.get(p).copied().unwrap_or(p as u32); + if let Some(slot) = parent.get_mut(i) { + *slot = grandparent; + } + i = grandparent as usize; + } +} + +fn union(parent: &mut [u32], a: usize, b: usize) { + let (a, b) = (find_root(parent, a), find_root(parent, b)); + if let Some(slot) = parent.get_mut(a.max(b)) { + *slot = a.min(b) as u32; + } +} + +/// Each mark's own pixels in its box: those of its runs, not of other marks the box overlaps. +fn pixels(mark: &Mark, runs: &[Run], order: &[u32]) -> Vec { + // A mark's box holds its runs, so these don't go below 0; a symbol is at most 256 a side. + let width = mark.x1.saturating_sub(mark.x0) as usize; + let height = mark.y1.saturating_sub(mark.y0) as usize; + let mut pixels = vec![0u8; width * height]; + for r in order.get(mark.runs.clone()).unwrap_or_default().iter().filter_map(|i| runs.get(*i as usize)) { + let row = r.y.saturating_sub(mark.y0) as usize * width; + let from = row + r.x0.saturating_sub(mark.x0) as usize; + let to = row + r.x1.saturating_sub(mark.x0) as usize; + pixels.get_mut(from..to).unwrap_or_default().fill(1); + } + pixels +} + +/// Sorts the marks into symbols: exact copies share one. `None` past [`MAX_SYMBOLS`] or +/// [`MAX_SYMBOL_PIXELS`]. +fn symbols(page: &mut Page) -> Option<()> { + let mut by_hash: HashMap> = HashMap::new(); + let mut kept = 0usize; + for (m, mark) in page.marks.iter().enumerate() { + let m = u32::try_from(m).ok()?; + let (width, height) = (mark.x1.saturating_sub(mark.x0), mark.y1.saturating_sub(mark.y0)); + if width > MAX_SYMBOL_SIDE || height > MAX_SYMBOL_SIDE { + page.large.push(m); + continue; + } + let pixels = pixels(mark, &page.runs, &page.order); + let mut hasher = DefaultHasher::new(); + (width, height, &pixels).hash(&mut hasher); + let same = by_hash.entry(hasher.finish()).or_default(); + let symbols = &mut page.symbols; + let known = + same.iter().copied().find(|s| symbols.get(*s as usize).is_some_and(|s| (s.width, s.height) == (width, height) && s.pixels == pixels)); + let id = match known { + Some(id) => id, + None => { + kept = kept.checked_add(pixels.len())?; + if kept > MAX_SYMBOL_PIXELS || symbols.len() == MAX_SYMBOLS { + return None; + } + let id = u32::try_from(symbols.len()).ok()?; + symbols.push(Symbol { width, height, pixels, uses: 0 }); + same.push(id); + id + } + }; + if let Some(s) = symbols.get_mut(id as usize) { + s.uses = s.uses.saturating_add(1); + } + page.copies.push((id, m)); + } + Some(()) +} + +/// One plan's stream: the symbols with at least `copies` uses in a symbol dictionary, text +/// regions placing them (in strips of 2^`log_strips` rows, or whichever size codes smallest), and +/// the other marks in a generic region. `None` with fewer than two such symbols (their IDs would +/// take no bits at all, SBSYMCODELEN 0, a corner of the standard left alone here) or more than +/// [`MAX_CODED_SYMBOLS`]. +fn plan(page: &Page, columns: u32, rows: u32, copies: u32, log_strips: Option) -> Option> { + let symbol = |s: u32| page.symbols.get(s as usize); + // The dictionary's order (6.5.5): height classes from the shortest, each narrowest first. + let mut kept: Vec = (0..page.symbols.len() as u32).filter(|s| symbol(*s).is_some_and(|s| s.uses >= copies)).collect(); + if kept.len() < 2 || kept.len() > MAX_CODED_SYMBOLS { + return None; + } + kept.sort_by_key(|s| symbol(*s).map(|s| (s.height, s.width))); + let mut id = vec![None; page.symbols.len()]; + for (k, s) in kept.iter().enumerate() { + if let Some(slot) = id.get_mut(*s as usize) { + *slot = u32::try_from(k).ok(); + } + } + let mut instances = Vec::new(); + let mut left = page.large.clone(); + for (s, m) in &page.copies { + match (id.get(*s as usize).copied().flatten(), page.marks.get(*m as usize), symbol(*s)) { + (Some(id), Some(mark), Some(symbol)) => { + instances.push(Instance { id, s: mark.x0, t: mark.y1.saturating_sub(1), width: symbol.width, height: symbol.height }) + } + _ => left.push(*m), + } + } + let kept: Vec<&Symbol> = kept.iter().filter_map(|s| symbol(*s)).collect(); + let dictionary = symbol_dictionary(&kept)?; + let count = u32::try_from(kept.len()).ok()?; + // Bands of at most MAX_REGION_INSTANCES, from the top. + instances.sort_by_key(|i| (i.t, i.s)); + let mut regions = Vec::new(); + for band in instances.chunks_mut(MAX_REGION_INSTANCES) { + let top = band.iter().map(|i| i.t.saturating_add(1).saturating_sub(i.height)).min()?; + let height = band.iter().map(|i| i.t).max()?.checked_sub(top)?.checked_add(1)?; + regions.push(match log_strips { + Some(l) => text_region(band, count, columns, top, height, l)?, + None => (0..4).filter_map(|l| text_region(band, count, columns, top, height, l)).min_by_key(Vec::len)?, + }); + } + let mut out = Vec::new(); + segment(&mut out, 0, 48, &[], false, &page_information(columns, rows)); + segment(&mut out, 1, 0, &[], true, &dictionary); + let mut number = 2; + for (k, text) in regions.iter().enumerate() { + // The dictionary stays retained for the next region, until the last (7.2.4). + segment(&mut out, number, 7, &[(1, k + 1 < regions.len())], false, text); + number += 1; + } + if !left.is_empty() { + segment(&mut out, number, 39, &[], false, &leftover_region(&left, page)?); + } + Some(out) +} + +/// A symbol dictionary segment's data (7.4.2) defining `symbols` (in dictionary order) and +/// exporting them all: arithmetic coded, no refinement or aggregation, each bitmap a generic +/// region with template 0, its nominal AT pixels and no typical prediction (Table 16), the +/// bitmaps sharing one set of contexts. +fn symbol_dictionary(symbols: &[&Symbol]) -> Option> { + let count = u32::try_from(symbols.len()).ok()?; + let mut data = vec![0, 0]; + at_pixels(&mut data); + data.extend_from_slice(&count.to_be_bytes()); + data.extend_from_slice(&count.to_be_bytes()); + let mut mq = MqEncoder::new(); + let mut contexts: Vec = vec![0; 1 << 16]; + let (mut iadh, mut iadw, mut iaex) = (IntCoder::new(), IntCoder::new(), IntCoder::new()); + let mut height = 0; + for class in symbols.chunk_by(|a, b| a.height == b.height) { + let h = class.first()?.height; + iadh.encode(&mut mq, Some(int(i64::from(h) - i64::from(height))?)); + height = h; + let mut width = 0; + for s in class { + iadw.encode(&mut mq, Some(int(i64::from(s.width) - i64::from(width))?)); + width = s.width; + let w = s.width as usize; + code_generic(&mut mq, &mut contexts, w, s.height as usize, false, |y, row| { + let line = s.pixels.get(y * w..).unwrap_or_default(); + row.iter_mut().zip(line).for_each(|(p, v)| *p = *v); + }); + } + iadw.encode(&mut mq, None); + } + // The export flags (6.5.10): a run of no symbols not exported, then a run of all of them. + iaex.encode(&mut mq, Some(0)); + iaex.encode(&mut mq, Some(int(i64::from(count))?)); + data.extend_from_slice(&mq.finish()); + Some(data) +} + +/// An immediate lossless text region segment's data (7.4.3) placing `instances` in the band of +/// `height` rows from row `top` (which holds them): arithmetic coded, no refinement, strips of +/// 2^`log_strips` rows, each symbol's bottom-left pixel at (S, T) (REFCORNER BOTTOMLEFT, so a +/// line's letters share their T), not transposed, OR, default pixel 0, SBDSOFFSET 0. Sorts +/// `instances` into strips, each left to right. +fn text_region(instances: &mut [Instance], symbols: u32, columns: u32, top: u32, height: u32, log_strips: u32) -> Option> { + // LOGSBSTRIPS has two bits: strips of 1, 2, 4 or 8 rows. + if log_strips > 3 { + return None; + } + let strips = 1i64 << log_strips; + // T counts from the band's top row. + let t = |i: &Instance| i.t.saturating_sub(top); + instances.sort_by_key(|i| (t(i) >> log_strips, i.s)); + let mut data = Vec::new(); + region_information(&mut data, columns, height, 0, top); + data.extend_from_slice(&((log_strips as u16) << 2).to_be_bytes()); + data.extend_from_slice(&u32::try_from(instances.len()).ok()?.to_be_bytes()); + // SBSYMCODELEN (Table 31): ⌈log2 SBNUMSYMS⌉. + let length = symbols.checked_next_power_of_two()?.trailing_zeros(); + let mut ids: Vec = vec![0; 1 << length]; + let (mut iadt, mut iafs, mut iads, mut iait) = (IntCoder::new(), IntCoder::new(), IntCoder::new(), IntCoder::new()); + let mut mq = MqEncoder::new(); + // The initial STRIPT (6.4.5 step 2) is 0, so each strip's delta T is its first row over the + // strip size, from the strip before. + iadt.encode(&mut mq, Some(0)); + let (mut strip_t, mut first_s) = (0i64, 0i64); + for strip in instances.chunk_by(|a, b| t(a) >> log_strips == t(b) >> log_strips) { + let first = i64::from(t(strip.first()?) >> log_strips << log_strips); + iadt.encode(&mut mq, Some(int((first - strip_t) / strips)?)); + strip_t = first; + let mut cur_s = 0; + for (k, instance) in strip.iter().enumerate() { + let s = i64::from(instance.s); + if k == 0 { + iafs.encode(&mut mq, Some(int(s - first_s)?)); + first_s = s; + } else { + iads.encode(&mut mq, Some(int(s - cur_s)?)); + } + if strips > 1 { + iait.encode(&mut mq, Some(int(i64::from(t(instance)) - strip_t)?)); + } + encode_id(&mut mq, &mut ids, length, instance.id); + // The next S counts from this symbol's last column (6.4.5 step 3c x). + cur_s = s + i64::from(instance.width) - 1; + } + iads.encode(&mut mq, None); + } + data.extend_from_slice(&mq.finish()); + Some(data) +} + +/// A generic region segment's data holding the marks `left`, over their box. +fn leftover_region(left: &[u32], page: &Page) -> Option> { + let left: Vec<&Mark> = left.iter().filter_map(|m| page.marks.get(*m as usize)).collect(); + let x0 = left.iter().map(|m| m.x0).min()?; + let y0 = left.iter().map(|m| m.y0).min()?; + let x1 = left.iter().map(|m| m.x1).max()?; + let y1 = left.iter().map(|m| m.y1).max()?; + let (width, height) = (x1.saturating_sub(x0) as usize, y1.saturating_sub(y0) as usize); + let stride = width.div_ceil(8); + // Packed, 1 for black: no larger than the image's own samples. + let mut packed = vec![0u8; stride.checked_mul(height)?]; + for mark in &left { + for r in page.order.get(mark.runs.clone()).unwrap_or_default().iter().filter_map(|i| page.runs.get(*i as usize)) { + let row = r.y.saturating_sub(y0) as usize * stride; + for x in r.x0.saturating_sub(x0) as usize..r.x1.saturating_sub(x0) as usize { + if let Some(b) = packed.get_mut(row + x / 8) { + *b |= 0x80 >> (x % 8); + } + } + } + } + let mut mq = MqEncoder::new(); + let mut contexts: Vec = vec![0; 1 << 16]; + code_generic(&mut mq, &mut contexts, width, height, true, |y, row| { + let line = packed.get(y * stride..).unwrap_or_default(); + for (x, p) in row.iter_mut().enumerate() { + *p = u8::from(line.get(x / 8).is_some_and(|b| b & (0x80 >> (x % 8)) != 0)); + } + }); + Some(generic_region(u32::try_from(width).ok()?, u32::try_from(height).ok()?, x0, y0, &mq.finish())) +} + +fn int(v: i64) -> Option { + i32::try_from(v).ok() +} + +/// An arithmetic integer coder other than IAID (A.2): its 512 contexts, indexed by PREV. +struct IntCoder([Context; 512]); + +impl IntCoder { + fn new() -> Self { + IntCoder([0; 512]) + } + + /// Codes `value`, or OOB for `None`. + fn encode(&mut self, mq: &mut MqEncoder, value: Option) { + for (cx, d) in decisions(value) { + if let Some(state) = self.0.get_mut(cx) { + mq.encode(state, d); + } + } + } +} + +/// The decisions that code `value`, or OOB for `None`, with their contexts (A.2, Table A.1): the +/// sign, the prefix that picks the range, then the offset into it, most significant bit first; +/// OOB is a negative 0. Each decision's context is PREV: a leading 1 and the decisions before it, +/// at most the last eight once there are more. +fn decisions(value: Option) -> Vec<(usize, bool)> { + let (negative, magnitude) = value.map_or((true, 0), |v| (v < 0, v.unsigned_abs())); + let (ones, bits, first) = + RANGES.into_iter().find(|(_, bits, first)| *bits == 32 || u64::from(magnitude) < u64::from(*first) + (1 << bits)).unwrap_or(RANGES[5]); + let offset = magnitude - first; + let mut d = vec![negative]; + d.extend(std::iter::repeat_n(true, ones as usize)); + if ones < 5 { + d.push(false); + } + d.extend((0..bits).rev().map(|i| offset >> i & 1 == 1)); + let mut prev = 1usize; + d.into_iter() + .map(|bit| { + let cx = prev; + prev = if prev < 256 { prev << 1 | usize::from(bit) } else { ((prev << 1 | usize::from(bit)) & 511) | 256 }; + (cx, bit) + }) + .collect() +} + +/// Codes symbol ID `id` in `length` bits, most significant first, through IAID (A.3): each +/// decision's context is a leading 1 and all the bits before it. +fn encode_id(mq: &mut MqEncoder, contexts: &mut [Context], length: u32, id: u32) { + let mut prev = 1usize; + for i in (0..length).rev() { + let d = id >> i & 1 == 1; + if let Some(state) = contexts.get_mut(prev) { + mq.encode(state, d); + } + prev = prev << 1 | usize::from(d); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::jbig2::tests::{decode, samples, unpack}; + use crate::jbig2::{encode_jbig2, encode_jbig2_generic}; + + type Bitmap = Vec>; + + /// A small linear congruential generator, so the pictures are the same every run. + struct Lcg(u64); + + impl Lcg { + fn below(&mut self, n: usize) -> usize { + self.0 = self.0.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407); + (self.0 >> 33) as usize % n.max(1) + } + } + + /// `count` different letter-like shapes: three strokes two pixels thick in a box of 6 to 18 + /// pixels a side, each after the first starting on one before it, so a shape is one mark; + /// trimmed to its pixels. + fn glyphs(count: usize, seed: u64) -> Vec { + let mut r = Lcg(seed); + let mut out: Vec = Vec::new(); + while out.len() < count { + let (w, h) = (6 + r.below(13), 6 + r.below(13)); + let mut g = vec![vec![0u8; w]; h]; + let (mut x, mut y) = (r.below(w), r.below(h)); + for _ in 0..3 { + let (dx, dy) = [(1, 0), (0, 1), (1, 1)][r.below(3)]; + let mut stroke = Vec::new(); + while x < w && y < h { + g[y][x] = 1; + g[y][(x + 1).min(w - 1)] = 1; + stroke.push((x, y)); + x += dx; + y += dy; + } + (x, y) = stroke[r.below(stroke.len())]; + } + let rows: Vec = (0..h).filter(|y| g[*y].contains(&1)).collect(); + let columns: Vec = (0..w).filter(|x| g.iter().any(|row| row[*x] == 1)).collect(); + let g: Bitmap = g[rows[0]..=rows[rows.len() - 1]].iter().map(|row| row[columns[0]..=columns[columns.len() - 1]].to_vec()).collect(); + if !out.contains(&g) { + out.push(g); + } + } + out + } + + /// Draws `glyph` with its top-left pixel at (`x`, `y`), clipped to the page, by OR. + fn draw(page: &mut Bitmap, glyph: &Bitmap, x: i64, y: i64) { + for (gy, row) in glyph.iter().enumerate() { + for (gx, p) in row.iter().enumerate() { + let (px, py) = (x + gx as i64, y + gy as i64); + if *p == 1 + && px >= 0 + && py >= 0 + && let Some(cell) = page.get_mut(py as usize).and_then(|r| r.get_mut(px as usize)) + { + *cell = 1; + } + } + } + } + + /// Lines of text: glyphs side by side, one to four pixels apart, each line's glyphs on its + /// baseline, the lines 30 rows apart; the first glyphs are the most common, as letters are. + fn text(columns: usize, rows: usize, glyphs: &[Bitmap], seed: u64) -> Bitmap { + let mut page = vec![vec![0u8; columns]; rows]; + let mut r = Lcg(seed); + for baseline in (24..rows).step_by(30) { + let mut x = 4 + r.below(8); + loop { + let common = r.below(glyphs.len()) + 1; + let g = &glyphs[r.below(common)]; + if x + g[0].len() > columns { + break; + } + draw(&mut page, g, x as i64, (baseline + 1 - g.len()) as i64); + x += g[0].len() + 1 + r.below(4); + } + } + page + } + + /// `n` glyphs anywhere, some over the edges, some over each other. + fn scatter(columns: usize, rows: usize, glyphs: &[Bitmap], seed: u64, n: usize) -> Bitmap { + let mut page = vec![vec![0u8; columns]; rows]; + let mut r = Lcg(seed); + for _ in 0..n { + let g = &glyphs[r.below(glyphs.len())]; + draw(&mut page, g, r.below(columns + 12) as i64 - 6, r.below(rows + 12) as i64 - 6); + } + page + } + + /// A segment's number and type, whether it is retained, and the segments it refers to, each + /// with its retain bit. + type Segment = (u32, u8, bool, Vec<(u8, bool)>); + + /// The segments of a stream in the embedded organisation, from their headers (7.2). + fn segments(data: &[u8]) -> Vec { + let (mut segments, mut at) = (Vec::new(), 0); + while at < data.len() { + let flags = data[at + 5]; + let refers = usize::from(flags >> 5); + let length_at = at + 6 + refers + 1; + let length = u32::from_be_bytes(data[length_at..length_at + 4].try_into().unwrap()) as usize; + let referred = data[at + 6..at + 6 + refers].iter().enumerate().map(|(k, n)| (*n, flags >> (k + 1) & 1 == 1)).collect(); + let number = u32::from_be_bytes(data[at..at + 4].try_into().unwrap()); + segments.push((number, data[at + 4] & 0x3F, flags & 1 == 1, referred)); + at = length_at + 4 + length; + } + segments + } + + fn segment_types(data: &[u8]) -> Vec { + segments(data).into_iter().map(|(_, kind, ..)| kind).collect() + } + + /// Every plan, at every strip size, decodes to the picture; returns how many plans there were. + fn every_plan_round_trips(picture: &Bitmap) -> usize { + let (columns, rows) = (picture[0].len() as u32, picture.len() as u32); + let data = samples(picture); + let page = page(&data, columns, rows).unwrap(); + let mut plans = 0; + for copies in [1, 2] { + for log_strips in 0..4 { + if let Some(coded) = plan(&page, columns, rows, copies, Some(log_strips)) { + assert_eq!(decode(&coded), unpack(&data, columns, rows), "{copies} copies, strips of {}", 1 << log_strips); + plans += 1; + } + } + } + plans + } + + /// Annex A.2's example (12 through IADW: the decisions 0101000, in contexts 1, 10, 101, …) + /// and Table A.1's codes. + #[test] + fn integers_are_coded_as_annex_a_says() { + assert_eq!(decisions(Some(12)), [(1, false), (2, true), (5, false), (10, true), (21, false), (42, false), (84, false)]); + let bits = |v| decisions(v).iter().map(|(_, d)| u8::from(*d)).collect::>(); + assert_eq!(bits(None), [1, 0, 0, 0], "OOB"); + assert_eq!(bits(Some(0)), [0, 0, 0, 0]); + assert_eq!(bits(Some(-1)), [1, 0, 0, 1]); + assert_eq!(bits(Some(-3)), [1, 0, 1, 1]); + assert_eq!(bits(Some(4)), [0, 1, 0, 0, 0, 0, 0]); + assert_eq!(bits(Some(-19)), [1, 1, 0, 1, 1, 1, 1]); + assert_eq!(bits(Some(83)), [0, 1, 1, 0, 1, 1, 1, 1, 1, 1]); + assert_eq!(bits(Some(4435))[..6], [0, 1, 1, 1, 1, 0]); + let big = bits(Some(4436)); + assert_eq!((big.len(), &big[..6]), (6 + 32, &[0, 1, 1, 1, 1, 1][..])); + assert!(big[6..].iter().all(|b| *b == 0)); + // PREV keeps a leading 1 and the last eight decisions, so it stays below 512, also when + // those eight are all 0 (PREV exactly 256), as in 4436's offset of 0. + for value in [Some(i32::MIN), Some(4436), Some(-4436), Some(4436 + 0x100), Some(i32::MAX), None] { + let contexts: Vec = decisions(value).iter().map(|(cx, _)| *cx).collect(); + assert!(contexts.iter().take(8).all(|cx| *cx < 256) && contexts.iter().skip(8).all(|cx| (256..512).contains(cx)), "{value:?}"); + } + assert!(decisions(Some(4436)).iter().any(|(cx, _)| *cx == 256)); + } + + /// Strips and first columns thousands of pixels apart need Annex A's 32-bit range: a mark at + /// the far right of a 12,000-pixel page, then one at the left below it (S back by 11,000), and + /// lines 5,000 rows apart. Each shape occurs twice, so both plans are made. + #[test] + fn coordinates_past_4435_round_trip() { + let shapes = glyphs(3, 4); + let mut picture = vec![vec![0u8; 12_000]; 40]; + for (k, x) in [11_000, 0, 5_000, 11_980].into_iter().enumerate() { + draw(&mut picture, &shapes[k % 2], x, 2 + 9 * k as i64); + } + assert_eq!(every_plan_round_trips(&picture), 8); + let mut tall = vec![vec![0u8; 30]; 10_050]; + for (k, y) in [0, 5_000, 10_030].into_iter().enumerate() { + draw(&mut tall, &shapes[k % 3], 3, y); + draw(&mut tall, &shapes[(k + 1) % 3], 15, y); + } + assert_eq!(every_plan_round_trips(&tall), 8); + } + + /// The padding bits of a row whose width isn't a multiple of 8 are ignored whatever they are: + /// black (0) padding, as many scans have, neither joins the marks nor stops symbol coding. + #[test] + fn black_padding_bits_are_ignored() { + for columns in [13, 241, 901] { + let picture = text(columns, 120, &glyphs(12, 6), 8); + let mut data = samples(&picture); + let stride = columns.div_ceil(8); + for row in data.chunks_mut(stride) { + row[stride - 1] &= 0xFF << (8 * stride - columns); + } + let (c, r) = (columns as u32, 120); + let page = page(&data, c, r).unwrap(); + assert!(page.marks.iter().all(|m| m.x1 as usize <= columns), "{columns} columns"); + for copies in [1, 2] { + if let Some(coded) = plan(&page, c, r, copies, None) { + assert_eq!(decode(&coded), unpack(&data, c, r), "{columns} columns"); + } + } + if columns > 13 { + assert_eq!(segment_types(&encode_jbig2(&data, c, r).unwrap())[1], 0, "{columns} columns: symbol coded"); + } + } + } + + /// Pixels touching only at a corner, down to the right or down to the left, are one mark + /// (8-connected); side by side with a white column between, two. A mark of 256 pixels a side + /// is a symbol; one 257 wide, or 257 tall, is left to the generic region. The diagonal and the + /// dot occur twice, so both plans are made. + #[test] + fn marks_join_at_corners_and_large_ones_are_left_out() { + let mut picture = vec![vec![0u8; 300]; 300]; + for i in 0..6 { + picture[10 + i][10 + i] = 1; + picture[10 + i][100 + i] = 1; + picture[10 + i][60 - i] = 1; + } + for row in &mut picture[0..257] { + row[280] = 1; + } + picture[30][10] = 1; + picture[30][12] = 1; + picture[40][0..256].fill(1); + picture[60][0..257].fill(1); + for row in &mut picture[44..300] { + row[290] = 1; + } + let data = samples(&picture); + let page = page(&data, 300, 300).unwrap(); + let boxes: Vec<(u32, u32, u32, u32)> = page.marks.iter().map(|m| (m.x0, m.y0, m.x1 - m.x0, m.y1 - m.y0)).collect(); + assert_eq!( + boxes, + [ + (280, 0, 1, 257), + (10, 10, 6, 6), + (55, 10, 6, 6), + (100, 10, 6, 6), + (10, 30, 1, 1), + (12, 30, 1, 1), + (0, 40, 256, 1), + (290, 44, 1, 256), + (0, 60, 257, 1) + ] + ); + let large: Vec<(u32, u32)> = page.large.iter().map(|m| (page.marks[*m as usize].x0, page.marks[*m as usize].y0)).collect(); + assert_eq!(large, [(280, 0), (0, 60)]); + assert_eq!(every_plan_round_trips(&picture), 8); + } + + /// Past 2^20 black runs or 2^24 rows the image is left to generic coding; at either cap it is + /// still looked at. + #[test] + fn busy_or_tall_images_are_left_to_generic_coding() { + // 0x55: four black pixels a byte, each its own run, under the same ones on the next row. + let columns = 1 << 20; + let row = vec![0x55u8; columns / 8]; + assert!(page(&row.repeat(2), columns as u32, 2).is_some(), "2^20 runs"); + assert!(page(&row.repeat(3), columns as u32, 3).is_none(), "1.5 × 2^20 runs"); + let tall = vec![0xFFu8; MAX_ROWS as usize + 1]; + assert!(page(&tall, 1, MAX_ROWS + 1).is_none()); + assert!(page(&tall, 1, MAX_ROWS).is_some()); + } + + /// A plan codes at most 32,767 symbols, which an independent decoder reads back; with more, + /// the second plan can still code the repeated ones. Past 65,536 distinct marks, or 2^24 + /// pixels of them, the image is left to generic coding; at either cap it is still looked at. + #[test] + fn too_many_symbols_are_left_to_generic_coding() { + // `n` distinct marks of 17 × 3 (full top and bottom rows joined by a code of 1 to n, in + // binary, on the row between), then `again` more with the codes 1 and 2 in turn. + let codes = |n: usize, again: usize| { + let (columns, rows) = (256 * 18, (n + again).div_ceil(256) * 4); + let mut picture = vec![vec![0u8; columns]; rows]; + for k in 0..n + again { + let (x, y) = (k % 256 * 18, k / 256 * 4); + let code = if k < n { k + 1 } else { (k - n) % 2 + 1 }; + picture[y][x..x + 17].fill(1); + picture[y + 2][x..x + 17].fill(1); + for b in (0..17).filter(|b| code >> b & 1 == 1) { + picture[y + 1][x + b] = 1; + } + } + (samples(&picture), columns as u32, rows as u32) + }; + let (data, columns, rows) = codes(MAX_CODED_SYMBOLS, 0); + let at_cap = page(&data, columns, rows).unwrap(); + assert_eq!(decode(&plan(&at_cap, columns, rows, 1, None).unwrap()), unpack(&data, columns, rows)); + // One more distinct mark, each once: neither plan is made. + let (data, columns, rows) = codes(MAX_CODED_SYMBOLS + 1, 0); + let past = page(&data, columns, rows).unwrap(); + assert!(plan(&past, columns, rows, 1, None).is_none() && plan(&past, columns, rows, 2, None).is_none()); + assert_eq!(encode_jbig2(&data, columns, rows).unwrap(), encode_jbig2_generic(&data, columns, rows).unwrap()); + // With two of them repeated, the second plan codes those two. + let (data, columns, rows) = codes(MAX_CODED_SYMBOLS + 1, 200); + let past = page(&data, columns, rows).unwrap(); + assert!(plan(&past, columns, rows, 1, None).is_none()); + assert_eq!(decode(&plan(&past, columns, rows, 2, None).unwrap()), unpack(&data, columns, rows)); + // Above a page of text whose letters repeat, that second plan is the one `encode_jbig2` + // keeps: the first, of every mark, would code too many symbols. + let mut picture: Bitmap = vec![vec![0u8; 256 * 18]; 512]; + for k in 0..=MAX_CODED_SYMBOLS { + let (x, y) = (k % 256 * 18, k / 256 * 4); + picture[y][x..x + 17].fill(1); + picture[y + 2][x..x + 17].fill(1); + for b in (0..17).filter(|b| (k + 1) >> b & 1 == 1) { + picture[y + 1][x + b] = 1; + } + } + picture.extend(text(256 * 18, 600, &glyphs(40, 7), 11)); + let data = samples(&picture); + let (columns, rows) = (picture[0].len() as u32, picture.len() as u32); + let coded = encode_jbig2(&data, columns, rows).unwrap(); + assert_eq!(segment_types(&coded)[1], 0, "symbol coded"); + assert_eq!(decode(&coded), unpack(&data, columns, rows)); + let (data, columns, rows) = codes(MAX_SYMBOLS, 0); + assert_eq!(page(&data, columns, rows).map(|p| p.symbols.len()), Some(MAX_SYMBOLS)); + let (data, columns, rows) = codes(MAX_SYMBOLS + 1, 0); + assert!(page(&data, columns, rows).is_none()); + // `n` black squares of 256 × 256, each with a white pixel in a different place: 2^16 + // pixels a symbol, so 256 of them are exactly the cap. + let squares = |n: usize| { + let (columns, rows) = (16 * 258, n.div_ceil(16) * 258); + let mut picture = vec![vec![0u8; columns]; rows]; + for k in 0..n { + let (x, y) = (k % 16 * 258, k / 16 * 258); + for row in &mut picture[y..y + 256] { + row[x..x + 256].fill(1); + } + picture[y + 1 + k / 254][x + 1 + k % 254] = 0; + } + (samples(&picture), columns as u32, rows as u32) + }; + let (data, columns, rows) = squares(256); + let at_cap = page(&data, columns, rows).unwrap(); + assert_eq!(at_cap.symbols.len(), 256); + assert_eq!(decode(&plan(&at_cap, columns, rows, 1, None).unwrap()), unpack(&data, columns, rows)); + let (data, columns, rows) = squares(257); + assert!(page(&data, columns, rows).is_none()); + } + + /// More than 10,000 marks are placed by several text regions, each of at most 10,000 (more + /// than the decoder PdfCraft renders with accepts in one), over bands of rows, all referring to + /// the dictionary, which each region but the last keeps retained; the generic region comes + /// after them, every segment numbered one more than the one before. + #[test] + fn more_than_10000_marks_are_placed_by_several_regions() { + // 25,000 copies of two 17 × 3 shapes, 256 a line, the lines 5 rows apart, and a bar too + // tall to be a symbol beside them. + let mut picture = vec![vec![0u8; 256 * 18 + 100]; 25_000usize.div_ceil(256) * 5]; + for k in 0..25_000 { + let (x, y) = (k % 256 * 18, k / 256 * 5); + picture[y][x..x + 17].fill(1); + picture[y + 2][x..x + 17].fill(1); + picture[y + 1][x + k % 2] = 1; + } + for row in &mut picture[0..300] { + row[256 * 18 + 50] = 1; + } + let data = samples(&picture); + let (columns, rows) = (picture[0].len() as u32, picture.len() as u32); + let coded = encode_jbig2(&data, columns, rows).unwrap(); + let region = |number, kept| (number, 7, false, vec![(1, kept)]); + assert_eq!( + segments(&coded), + [(0, 48, false, vec![]), (1, 0, true, vec![]), region(2, true), region(3, true), region(4, false), (5, 39, false, vec![])] + ); + assert_eq!(decode(&coded), unpack(&data, columns, rows)); + assert_eq!(every_plan_round_trips(&picture), 8); + } + + /// A page of text whose letters repeat exactly, as rendered text does, keeps each letter once: + /// a symbol dictionary and a text region, much smaller than the generic coding. + #[test] + fn repeated_marks_are_stored_once() { + let picture = text(1700, 400, &glyphs(40, 7), 11); + let data = samples(&picture); + let coded = encode_jbig2(&data, 1700, 400).unwrap(); + let generic = encode_jbig2_generic(&data, 1700, 400).unwrap(); + // The dictionary is retained for the text region, which refers to it (7.2.4, 7.2.5). + assert_eq!(segments(&coded), [(0, 48, false, vec![]), (1, 0, true, vec![]), (2, 7, false, vec![(1, false)])]); + assert_eq!(decode(&coded), unpack(&data, 1700, 400)); + assert!(coded.len() * 2 < generic.len(), "symbols {} bytes, generic {}", coded.len(), generic.len()); + assert_eq!(every_plan_round_trips(&picture), 8); + } + + /// Marks too large to be symbols (a frame, a rule) go to a generic region over their box, and + /// with the second plan so do marks that occur once; the page still decodes to its bits. + #[test] + fn large_and_lone_marks_are_left_to_a_generic_region() { + let mut picture = text(900, 300, &glyphs(25, 3), 5); + picture[10][100..800].fill(1); + picture[290][100..800].fill(1); + for row in &mut picture[10..=290] { + row[100] = 1; + row[799] = 1; + } + // A lone blob, unlike any letter. + for (y, row) in picture[150..170].iter_mut().enumerate() { + row[850 + y % 7] = 1; + } + let data = samples(&picture); + let coded = encode_jbig2(&data, 900, 300).unwrap(); + assert_eq!(segment_types(&coded), [48, 0, 7, 39]); + assert_eq!(decode(&coded), unpack(&data, 900, 300)); + assert_eq!(every_plan_round_trips(&picture), 8); + } + + /// Marks on every edge, marks touching only at a corner (one mark), a mark inside another's + /// box, and marks over each other's boxes. + #[test] + fn marks_at_the_edges_and_corners_round_trip() { + let dot = vec![vec![1u8, 1], vec![1, 1]]; + let ell = vec![vec![1u8, 0, 0], vec![1, 0, 0], vec![1, 1, 1]]; + let mut picture = vec![vec![0u8; 41]; 23]; + for (x, y) in [(0, 0), (39, 0), (0, 21), (39, 21), (20, 0), (0, 10), (39, 10), (20, 21)] { + draw(&mut picture, &dot, x, y); + } + for (x, y) in [(10, 5), (28, 14), (12, 14)] { + draw(&mut picture, &ell, x, y); + } + // Corners touching: one mark (`marks_join_at_corners_and_large_ones_are_left_out` checks it). + picture[3][6] = 1; + picture[4][7] = 1; + picture[5][8] = 1; + // A ring with a dot inside, and an ell over the ring's box. + for i in 0..7 { + picture[8][20 + i] = 1; + picture[14][20 + i] = 1; + picture[8 + i][20] = 1; + picture[8 + i][26] = 1; + } + draw(&mut picture, &dot, 22, 10); + draw(&mut picture, &ell, 25, 15); + assert_eq!(every_plan_round_trips(&picture), 8); + } + + /// One shape, however often, would need symbol IDs of no bits: that image is coded as a generic + /// region, as are a blank page and pure noise. + #[test] + fn one_shape_blank_pages_and_noise_are_coded_generically() { + let letter = &glyphs(1, 9)[..]; + let mut r = Lcg(1); + let noise: Bitmap = (0..60).map(|_| (0..200).map(|_| u8::from(r.below(2) == 1)).collect()).collect(); + for picture in [text(600, 200, letter, 2), vec![vec![0u8; 300]; 40], noise] { + let (columns, rows) = (picture[0].len() as u32, picture.len() as u32); + let data = samples(&picture); + assert_eq!(encode_jbig2(&data, columns, rows).unwrap(), encode_jbig2_generic(&data, columns, rows).unwrap()); + } + } + + proptest::proptest! { + /// Glyphs anywhere, over the edges and each other, with stray pixels: every plan decodes to + /// the picture, and `encode_jbig2` is never larger than the generic coding. + #[test] + fn scattered_marks_round_trip( + columns in 1usize..240, + rows in 1usize..120, + shapes in 2usize..10, + n in 0usize..80, + seed in proptest::num::u64::ANY, + stray in proptest::collection::vec((0usize..240, 0usize..120), 0..20), + ) { + let mut picture = scatter(columns, rows, &glyphs(shapes, seed), seed, n); + for (x, y) in stray { + if let Some(cell) = picture.get_mut(y).and_then(|r| r.get_mut(x)) { + *cell ^= 1; + } + } + every_plan_round_trips(&picture); + let data = samples(&picture); + let (c, r) = (columns as u32, rows as u32); + let coded = encode_jbig2(&data, c, r).unwrap(); + proptest::prop_assert!(coded.len() <= encode_jbig2_generic(&data, c, r).unwrap().len()); + proptest::prop_assert_eq!(decode(&coded), unpack(&data, c, r)); + } + + /// Whatever it is given, it returns. + #[test] + fn any_input_returns(data in proptest::collection::vec(0u8..=255, 0..600), columns in 0u32..5000, rows in 0u32..80) { + let _ = encode_jbig2(&data, columns, rows); + let _ = encode(&data, columns, rows); + } + } +} diff --git a/crates/filters/src/lib.rs b/crates/filters/src/lib.rs index 22a78aefdb..181f96ab66 100644 --- a/crates/filters/src/lib.rs +++ b/crates/filters/src/lib.rs @@ -6,7 +6,28 @@ //! TIFF/PNG predictors, `ASCIIHexDecode`, `ASCII85Decode`, `RunLengthDecode`). //! Image codecs (`DCTDecode`, `JPXDecode`, `JBIG2Decode`, `CCITTFaxDecode`), //! `Crypt` and unknown filters are reported as [`FilterError::Unsupported`] so -//! callers keep such data encoded. +//! callers keep such data encoded. CCITT fax and JBIG2 images decode on their own +//! ([`decode_ccitt_within`], [`decode_jbig2_within`]), each spending from an allowance its caller +//! shares across a run of images, as `pdfcraft_cos::ImageBudget::decode` does after the filters +//! before them. Neither decodes without one: +//! +//! ```compile_fail +//! let _ = pdfcraft_filters::decode_jbig2(&[], None, 1, 1, 1 << 20); +//! ``` +//! +//! ```compile_fail +//! let _ = pdfcraft_filters::decode_ccitt(&[], &pdfcraft_filters::CcittParams::default(), 1); +//! ``` +//! +//! ``` +//! let mut work = 1 << 20; +//! assert!(pdfcraft_filters::decode_jbig2_within(&[], None, 1, 1, 1 << 20, &mut work).is_err()); +//! let p = pdfcraft_filters::CcittParams { columns: 8, ..Default::default() }; +//! assert!(pdfcraft_filters::decode_ccitt_within(&[], &p, 1, &mut work).is_ok()); +//! ``` +//! +//! 1-bit samples can be encoded as CCITT Group 4 ([`encode_g4`]) or lossless JBIG2 +//! ([`encode_jbig2`]). //! //! Every decoder is bounded by a caller-supplied `max_output` (decompression-bomb //! defence) and never panics on malformed input. @@ -15,13 +36,19 @@ mod ascii85; mod asciihex; +mod ccitt; mod flate; +mod jbig2; mod lzw; +mod mq; mod predictor; mod runlength; use std::borrow::Cow; +pub use ccitt::{CcittParams, MAX_CCITT_COLUMNS, MAX_G4_COLUMNS, decode_ccitt_within, encode_g4}; +pub use jbig2::{MAX_JBIG2_COLUMNS, MAX_JBIG2_PIXELS, decode_jbig2_within, encode_jbig2, encode_jbig2_generic}; + /// A PDF stream filter, identified by its `/Filter` name. #[derive(Clone, Debug, PartialEq, Eq, Hash)] pub enum Filter { @@ -82,7 +109,8 @@ impl Filter { matches!(self, Filter::Dct | Filter::Jpx | Filter::Jbig2 | Filter::CcittFax) } - fn is_decodable(&self) -> bool { + /// True for the general-purpose filters, which a chain can hold ([`decode`]). + pub fn is_decodable(&self) -> bool { matches!(self, Filter::Flate | Filter::Lzw | Filter::AsciiHex | Filter::Ascii85 | Filter::RunLength) } } @@ -114,33 +142,53 @@ pub enum FilterError { LimitExceeded(usize), #[error("unsupported filter: {0}")] Unsupported(String), + /// A run of images has nothing left to spend ([`decode_tolerant_within`]). + #[error("more image decoding than one operation may do")] + Spent, } /// A decoder failure carrying whatever was decoded before the error. pub(crate) struct Failure { pub(crate) error: FilterError, pub(crate) partial: Vec, + /// Bytes decoded on the way that `partial` doesn't hold: they took as long. + pub(crate) discarded: usize, } impl Failure { pub(crate) fn corrupt(filter: &'static str, detail: impl Into, partial: Vec) -> Self { - Failure { error: FilterError::Corrupt { filter, detail: detail.into() }, partial } + Failure { error: FilterError::Corrupt { filter, detail: detail.into() }, partial, discarded: 0 } } pub(crate) fn limit(max: usize) -> Self { - Failure { error: FilterError::LimitExceeded(max), partial: Vec::new() } + Failure { error: FilterError::LimitExceeded(max), partial: Vec::new(), discarded: 0 } } } pub(crate) type Step = Result, Failure>; +/// Takes `steps` from `work`, what a run of images may still cost. What it can't pay for spends +/// all of it. +pub(crate) fn pay(work: &mut u64, steps: u64) -> Result<(), FilterError> { + match work.checked_sub(steps) { + Some(left) => { + *work = left; + Ok(()) + } + None => { + *work = 0; + Err(FilterError::Spent) + } + } +} + /// Decodes `data` through `chain` (first element applied first). /// /// Image codecs, `Crypt` and unknown filters anywhere in the chain give /// [`FilterError::Unsupported`] (callers keep such data encoded), as does a chain longer than /// [`MAX_CHAIN`]. `max_output` bounds every intermediate buffer. pub fn decode(chain: &[(Filter, Params)], data: &[u8], max_output: usize) -> Result, FilterError> { - run(chain, data, max_output, true, false).map(|(v, _, _)| v).map_err(|(e, _)| e) + run(chain, data, max_output, None, true).map(|(v, _, _)| v).map_err(|(e, _)| e) } /// Like [`decode`] for a caller charging an allowance: `max_output` bounds the bytes all stages @@ -148,47 +196,98 @@ pub fn decode(chain: &[(Filter, Params)], data: &[u8], max_output: usize) -> Res /// bytes, on success and on failure. A stage's output counts even when a later stage or its /// predictor shrinks it. pub fn decode_counted(chain: &[(Filter, Params)], data: &[u8], max_output: usize) -> Result<(Vec, usize), (FilterError, usize)> { - run(chain, data, max_output, true, true).map(|(v, _, spent)| (v, spent)) + run(chain, data, max_output, Some(max_output), true).map(|(v, _, spent)| (v, spent)) } /// Like [`decode`], but on corrupt data returns what was decoded before the error and /// reports `partial = true` (the behaviour viewers expect). The remaining filters of the /// chain still run on the partial data. Limit and unsupported errors are still errors. pub fn decode_tolerant(chain: &[(Filter, Params)], data: &[u8], max_output: usize) -> Result<(Vec, bool), FilterError> { - run(chain, data, max_output, false, false).map(|(v, partial, _)| (v, partial)).map_err(|(e, _)| e) + run(chain, data, max_output, None, false).map(|(v, partial, _)| (v, partial)).map_err(|(e, _)| e) } /// [`decode_tolerant`] bounded and counted as [`decode_counted`] is. pub fn decode_tolerant_counted(chain: &[(Filter, Params)], data: &[u8], max_output: usize) -> Result<(Vec, bool, usize), (FilterError, usize)> { - run(chain, data, max_output, false, true) + run(chain, data, max_output, Some(max_output), false) +} + +/// [`decode_tolerant`], spending from `work`, what decoding may still cost across a run of images: +/// a step for each byte of `data`, paid before anything is decoded, and one for each byte a stage +/// decodes, so a chain that fails, or a stage that goes past `max_output`, has paid for what it did. +/// No stage decodes past what is left: a chain that `work` can't pay for spends all of it and stops +/// there, so with nothing left nothing is read. A chain that can't be decoded at all costs nothing, +/// whatever is left, so what an image's filters are is known from the error. +/// +/// Unlike [`decode_tolerant`], no filters at all gives `data` itself, within `max_output`. +pub fn decode_tolerant_within(chain: &[(Filter, Params)], data: &[u8], max_output: usize, work: &mut u64) -> Result, FilterError> { + spending(chain, data, max_output, work, false) +} + +/// [`decode`], spending from `work` as [`decode_tolerant_within`] does: corrupt data is an error, +/// having paid for what was decoded before it. +pub fn decode_within(chain: &[(Filter, Params)], data: &[u8], max_output: usize, work: &mut u64) -> Result, FilterError> { + spending(chain, data, max_output, work, true) +} + +fn spending(chain: &[(Filter, Params)], data: &[u8], max_output: usize, work: &mut u64, strict: bool) -> Result, FilterError> { + decodable(chain)?; + pay(work, data.len() as u64)?; + if chain.is_empty() { + return if data.len() > max_output { Err(FilterError::LimitExceeded(max_output)) } else { Ok(data.to_vec()) }; + } + let left = usize::try_from(*work).unwrap_or(usize::MAX); + match run(chain, data, max_output, Some(left), strict) { + Ok((out, _, decoded)) => pay(work, decoded as u64).map(|()| out), + // The stage stopped at the lower of the two, which took as much to find out. + Err((FilterError::LimitExceeded(_), decoded)) if left.saturating_sub(decoded) > max_output => { + pay(work, (decoded as u64).saturating_add(max_output as u64))?; + Err(FilterError::LimitExceeded(max_output)) + } + Err((FilterError::LimitExceeded(_), _)) => { + *work = 0; + Err(FilterError::Spent) + } + Err((e, decoded)) => pay(work, decoded as u64).and(Err(e)), + } } /// Longest filter chain decoded. Real streams use one to three filters; every stage may produce /// up to the output limit, so the chain length multiplies the work. pub const MAX_CHAIN: usize = 8; -/// The decoded data, whether it is partial and the bytes all stages decoded, or the error and -/// the bytes decoded before it. `max` bounds each stage, or with `cumulative` all of them -/// together. -fn run(chain: &[(Filter, Params)], data: &[u8], max: usize, strict: bool, cumulative: bool) -> Result<(Vec, bool, usize), (FilterError, usize)> { +/// Whether a chain is one [`run`] takes, and if not, the error. +fn decodable(chain: &[(Filter, Params)]) -> Result<(), FilterError> { if chain.len() > MAX_CHAIN { - return Err((FilterError::Unsupported(format!("a chain of {} filters", chain.len())), 0)); + return Err(FilterError::Unsupported(format!("a chain of {} filters", chain.len()))); } - if let Some((f, _)) = chain.iter().find(|(f, _)| !f.is_decodable()) { - return Err((FilterError::Unsupported(f.name().to_owned()), 0)); + match chain.iter().find(|(f, _)| !f.is_decodable()) { + Some((f, _)) => Err(FilterError::Unsupported(f.name().to_owned())), + None => Ok(()), } +} + +/// The decoded data, whether it is partial and the bytes all stages decoded, or the error and +/// the bytes decoded before it. `max` bounds each stage, and `total` all of them together. +fn run( + chain: &[(Filter, Params)], + data: &[u8], + max: usize, + total: Option, + strict: bool, +) -> Result<(Vec, bool, usize), (FilterError, usize)> { + decodable(chain).map_err(|e| (e, 0))?; let mut cur: Cow<[u8]> = Cow::Borrowed(data); let mut partial = false; let mut spent = 0usize; for (filter, params) in chain { #[cfg(test)] STAGE_BYTES.with(|c| c.set(c.get() + cur.len())); - let limit = if cumulative { max.saturating_sub(spent) } else { max }; + let limit = total.map_or(max, |total| max.min(total.saturating_sub(spent))); let (step, work) = decode_one(filter, params, &cur, limit); spent = spent.saturating_add(work); cur = Cow::Owned(match step { Ok(v) => v, - Err(Failure { error: FilterError::Corrupt { .. }, partial: out }) if !strict => { + Err(Failure { error: FilterError::Corrupt { .. }, partial: out, .. }) if !strict => { partial = true; out } @@ -207,15 +306,15 @@ fn decode_one(filter: &Filter, params: &Params, data: &[u8], max: usize) -> (Ste Filter::AsciiHex => counted(asciihex::decode(data, max)), Filter::Ascii85 => counted(ascii85::decode(data, max)), Filter::RunLength => counted(runlength::decode(data, max)), - other => (Err(Failure { error: FilterError::Unsupported(other.name().to_owned()), partial: Vec::new() }), 0), + other => (Err(Failure { error: FilterError::Unsupported(other.name().to_owned()), partial: Vec::new(), discarded: 0 }), 0), } } -/// A step with the bytes it produced, its output or its partial output. +/// A step with the bytes it produced: its output, or its partial output and what it threw away. fn counted(step: Step) -> (Step, usize) { let n = match &step { Ok(v) => v.len(), - Err(f) => f.partial.len(), + Err(f) => f.partial.len().saturating_add(f.discarded), }; (step, n) } @@ -227,12 +326,12 @@ fn with_predictor(step: Step, params: &Params, name: &'static str) -> (Step, usi let (step, n) = counted(step); let step = match step { Ok(v) => predictor::decode(params, v, name), - Err(Failure { error, partial }) => { + Err(Failure { error, partial, discarded }) => { let partial = match predictor::decode(params, partial, name) { Ok(v) => v, Err(f) => f.partial, }; - Err(Failure { error, partial }) + Err(Failure { error, partial, discarded }) } }; (step, n) @@ -419,6 +518,9 @@ mod tests { assert_eq!(decode(&chain, &data, max).map(|d| d.len()).ok(), Some(1 << 19)); assert!(matches!(decode_counted(&chain, &data, max), Err((FilterError::LimitExceeded(m), _)) if m == max)); assert_eq!(decode_counted(&chain, &data, 2 << 20).map(|(_, spent)| spent).ok(), Some((1 << 20) + (1 << 19))); + let tolerant = decode_tolerant_counted(&chain, &data, 2 << 20).map(|(out, partial, spent)| (out.len(), partial, spent)); + assert_eq!(tolerant.ok(), Some((1 << 19, false, (1 << 20) + (1 << 19)))); + assert!(matches!(decode_tolerant_counted(&chain, &data, max), Err((FilterError::LimitExceeded(m), _)) if m == max)); } /// A PNG predictor drops a tag byte per row, but Flate produced it: the count includes it, @@ -437,4 +539,157 @@ mod tests { assert!(matches!(e, FilterError::Corrupt { .. }), "{e}"); assert_eq!(spent, bad.len()); } + + fn refused(e: &FilterError) -> bool { + matches!(e, FilterError::Spent) + } + + /// A chain pays for what it reads and what each stage decodes, down to the byte: Flate inflates + /// hex that ASCIIHex halves, so a stage counts when a later one shrinks it. + #[test] + fn a_chain_spends_down_to_the_byte() { + let hex = b"ab".repeat(1 << 10); + let data = encode_flate(&hex); + let chain = [(Filter::Flate, Params::default()), (Filter::AsciiHex, Params::default())]; + let whole = decode(&chain, &data, 1 << 20).unwrap(); + let cost = (data.len() + hex.len() + whole.len()) as u64; + let mut work = u64::MAX; + assert_eq!(decode_tolerant_within(&chain, &data, 1 << 20, &mut work).unwrap(), whole); + assert_eq!(u64::MAX - work, cost); + let mut work = cost; + assert_eq!(decode_tolerant_within(&chain, &data, 1 << 20, &mut work).unwrap(), whole, "exactly enough"); + assert_eq!(work, 0); + let mut work = cost + 7; + decode_tolerant_within(&chain, &data, 1 << 20, &mut work).unwrap(); + assert_eq!(work, 7, "only what it took"); + let mut work = cost - 1; + assert!(refused(&decode_tolerant_within(&chain, &data, 1 << 20, &mut work).unwrap_err()), "a step short"); + assert_eq!(work, 0, "spends all that was left"); + // Each stage has `max_output` to itself, as in a plain decode: together they are past it. + let mut work = u64::MAX; + assert_eq!(decode_tolerant_within(&chain, &data, hex.len(), &mut work).unwrap(), whole); + } + + /// No stage runs past what is left: the first stage of this bomb stops there, so the second + /// isn't given anything to read, and with too little to read it nothing runs at all. + #[test] + fn a_chain_stops_at_what_is_left() { + let inner = encode_flate(&vec![0; 1 << 20]); + let bomb = encode_flate(&inner); + let chain = [(Filter::Flate, Params::default()), (Filter::Flate, Params::default())]; + let read = |work: u64| { + super::STAGE_BYTES.with(|c| c.set(0)); + let mut work = work; + assert!(refused(&decode_tolerant_within(&chain, &bomb, usize::MAX, &mut work).unwrap_err())); + assert_eq!(work, 0); + super::STAGE_BYTES.with(|c| c.get()) + }; + assert_eq!(read((bomb.len() + inner.len()) as u64 + 10), bomb.len() + inner.len(), "the second stage stops"); + assert_eq!(read(bomb.len() as u64 + 10), bomb.len(), "the first stops, and the second never runs"); + assert_eq!(read(bomb.len() as u64 - 1), 0, "it can't pay to be read"); + } + + /// Past `max_output` the chain has failed, and hasn't run out of anything: it pays what it took + /// to find out, and what is left stays. Whichever limit a stage stops at is the one reported. + #[test] + fn a_stage_past_its_limit_pays_for_finding_out() { + let data = encode_flate(&vec![0; 1 << 16]); + let chain = [(Filter::Flate, Params::default())]; + let mut work = u64::MAX; + assert!(matches!(decode_tolerant_within(&chain, &data, 1000, &mut work), Err(FilterError::LimitExceeded(1000)))); + assert_eq!(u64::MAX - work, data.len() as u64 + 1000); + // The later stage of two, which pays for the first as well. + let bomb = encode_flate(&data); + let two = [(Filter::Flate, Params::default()), (Filter::Flate, Params::default())]; + let mut work = u64::MAX; + assert!(matches!(decode_tolerant_within(&two, &bomb, data.len(), &mut work), Err(FilterError::LimitExceeded(_)))); + assert_eq!(u64::MAX - work, (bomb.len() + 2 * data.len()) as u64); + // Both limits at once: a run that can't go on is what a caller needs to hear. + let mut work = data.len() as u64 + 1000; + assert!(refused(&decode_tolerant_within(&chain, &data, 1000, &mut work).unwrap_err())); + assert_eq!(work, 0); + let mut work = data.len() as u64 + 1001; + assert!(matches!(decode_tolerant_within(&chain, &data, 1000, &mut work), Err(FilterError::LimitExceeded(1000)))); + assert_eq!(work, 1, "the image's limit, with a step to spare"); + } + + /// What a damaged stream decoded is paid for, however the decode ends. + #[test] + fn a_chain_that_fails_has_paid_for_what_it_did() { + let data: Vec = (0..20_000u32).map(|i| (i % 97) as u8 ^ (i / 300) as u8).collect(); + let mut cut = encode_flate(&data); + cut.truncate(cut.len() / 2); + let chain = [(Filter::Flate, Params::default())]; + let (partial, _) = decode_tolerant(&chain, &cut, 1 << 20).unwrap(); + assert!(!partial.is_empty()); + let mut work = u64::MAX; + assert_eq!(decode_tolerant_within(&chain, &cut, 1 << 20, &mut work).unwrap(), partial); + assert_eq!(u64::MAX - work, (cut.len() + partial.len()) as u64); + // Read strictly it is an error, for the same work. + let mut work = u64::MAX; + assert!(matches!(decode_within(&chain, &cut, 1 << 20, &mut work), Err(FilterError::Corrupt { .. }))); + assert_eq!(u64::MAX - work, (cut.len() + partial.len()) as u64); + let whole = encode_flate(&data); + let mut work = u64::MAX; + assert_eq!(decode_within(&chain, &whole, 1 << 20, &mut work).unwrap(), data); + assert_eq!(u64::MAX - work, (whole.len() + data.len()) as u64); + // A predictor that comes to nothing, after all of this was inflated for it. + let p = Params { predictor: 99, ..Params::default() }; + let data = encode_flate(&vec![0; 1 << 16]); + let mut work = u64::MAX; + assert!(decode_tolerant_within(&[(Filter::Flate, p)], &data, 1 << 20, &mut work).unwrap().is_empty()); + assert_eq!(u64::MAX - work, data.len() as u64 + (1 << 16)); + } + + /// A stream that reads as zlib only until it fails, and as raw deflate until that fails too, + /// gives back no data, and has still decoded everything the retry did. + #[test] + fn a_retry_that_fails_pays_for_what_it_decoded() { + // Stored as raw deflate: this many bytes, then a block of a reserved type. As zlib: the + // same reserved type at once, so the first reading fails with nothing. + let mut data = vec![0x78, 0x01, 0x06, 0xFE, 0xF9]; + data.extend(vec![0u8; 0x0601]); + data.push(0x07); + let chain = [(Filter::Flate, Params::default())]; + let (out, partial) = decode_tolerant(&chain, &data, 1 << 20).unwrap(); + assert!(out.is_empty() && partial, "what is read stays as it was"); + let (_, _, spent) = decode_tolerant_counted(&chain, &data, 1 << 20).unwrap(); + assert_eq!(spent, 0x0601); + let (e, spent) = decode_counted(&chain, &data, 1 << 20).unwrap_err(); + assert!(matches!(e, FilterError::Corrupt { .. }), "{e}"); + assert_eq!(spent, 0x0601); + let mut work = u64::MAX; + assert!(decode_tolerant_within(&chain, &data, 1 << 20, &mut work).unwrap().is_empty()); + assert_eq!(u64::MAX - work, (data.len() + 0x0601) as u64); + // Through a predictor, which would otherwise lose the count. + let p = Params { predictor: 12, columns: 4, ..Params::default() }; + let (_, _, spent) = decode_tolerant_counted(&[(Filter::Flate, p)], &data, 1 << 20).unwrap(); + assert_eq!(spent, 0x0601); + } + + /// With nothing left nothing is read. What can't be decoded at all never costs anything, and + /// says so whatever is left. Without filters, what is read is the data. + #[test] + fn what_a_chain_is_shows_whatever_is_left() { + let flate = [(Filter::Flate, Params::default())]; + let mut work = 0; + assert!(refused(&decode_tolerant_within(&flate, b"garbage", 1 << 20, &mut work).unwrap_err())); + let long = vec![(Filter::Flate, Params::default()); MAX_CHAIN + 1]; + for chain in [vec![(Filter::Dct, Params::default())], vec![(Filter::Jbig2, Params::default())], long] { + for left in [0, 100] { + let mut work = left; + let e = decode_tolerant_within(&chain, b"data", 1 << 20, &mut work).unwrap_err(); + assert!(matches!(e, FilterError::Unsupported(_)) && !refused(&e), "{e}"); + assert_eq!(work, left); + } + } + let mut work = 100; + assert_eq!(decode_tolerant_within(&[], b"data", 4, &mut work).unwrap(), b"data"); + assert_eq!(work, 96); + assert!(matches!(decode_tolerant_within(&[], b"data", 3, &mut work), Err(FilterError::LimitExceeded(3)))); + assert_eq!(work, 92); + let mut work = 3; + assert!(refused(&decode_tolerant_within(&[], b"data", 4, &mut work).unwrap_err())); + assert_eq!(work, 0); + } } diff --git a/crates/filters/src/mq.rs b/crates/filters/src/mq.rs new file mode 100644 index 0000000000..04dda2bd9a --- /dev/null +++ b/crates/filters/src/mq.rs @@ -0,0 +1,209 @@ +//! The MQ arithmetic encoder of JBIG2 (ITU-T T.88 | ISO/IEC 14492, Annex E), written from the +//! procedures in its Figures E.2–E.12 and the probability estimates of Table E.1. + +/// Table E.1: for each index, the LPS probability estimate Qe, the next index after an MPS and +/// after an LPS renormalisation, and whether an LPS switches the MPS sense. +const STATES: [(u32, u8, u8, bool); 47] = [ + (0x5601, 1, 1, true), + (0x3401, 2, 6, false), + (0x1801, 3, 9, false), + (0x0AC1, 4, 12, false), + (0x0521, 5, 29, false), + (0x0221, 38, 33, false), + (0x5601, 7, 6, true), + (0x5401, 8, 14, false), + (0x4801, 9, 14, false), + (0x3801, 10, 14, false), + (0x3001, 11, 17, false), + (0x2401, 12, 18, false), + (0x1C01, 13, 20, false), + (0x1601, 29, 21, false), + (0x5601, 15, 14, true), + (0x5401, 16, 14, false), + (0x5101, 17, 15, false), + (0x4801, 18, 16, false), + (0x3801, 19, 17, false), + (0x3401, 20, 18, false), + (0x3001, 21, 19, false), + (0x2801, 22, 19, false), + (0x2401, 23, 20, false), + (0x2201, 24, 21, false), + (0x1C01, 25, 22, false), + (0x1801, 26, 23, false), + (0x1601, 27, 24, false), + (0x1401, 28, 25, false), + (0x1201, 29, 26, false), + (0x1101, 30, 27, false), + (0x0AC1, 31, 28, false), + (0x09C1, 32, 29, false), + (0x08A1, 33, 30, false), + (0x0521, 34, 31, false), + (0x0441, 35, 32, false), + (0x02A1, 36, 33, false), + (0x0221, 37, 34, false), + (0x0141, 38, 35, false), + (0x0111, 39, 36, false), + (0x0085, 40, 37, false), + (0x0049, 41, 38, false), + (0x0025, 42, 39, false), + (0x0015, 43, 40, false), + (0x0009, 44, 41, false), + (0x0005, 45, 42, false), + (0x0001, 45, 43, false), + (0x5601, 46, 46, false), +]; + +#[cfg(test)] +thread_local! { + /// Decisions coded on this thread. + pub(crate) static DECISIONS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// A context's adaptive state: the index into [`STATES`] (low 7 bits) and the MPS sense (bit 7). +/// 0, the initial value, is index 0 with MPS 0 (E.2.8). +pub(crate) type Context = u8; + +/// The encoder's registers (E.2.1) and the bytes written so far. +pub(crate) struct MqEncoder { + a: u32, + c: u32, + ct: u32, + /// The output, after the byte before the first one (B at INITENC), which is 0x00 and is + /// dropped by [`MqEncoder::finish`]: a carry can't reach it before the first byte is out. + out: Vec, +} + +impl MqEncoder { + /// INITENC (Figure E.10). The byte before the first is 0x00, not 0xFF, so CT starts at 12. + pub(crate) fn new() -> Self { + MqEncoder { a: 0x8000, c: 0, ct: 12, out: vec![0] } + } + + /// The byte B, the last one written (or the one before the first). + fn b(&self) -> u8 { + self.out.last().copied().unwrap_or(0) + } + + /// ENCODE (Figures E.3–E.7): code decision `d` in context `cx`, updating its state. + pub(crate) fn encode(&mut self, cx: &mut Context, d: bool) { + #[cfg(test)] + DECISIONS.with(|n| n.set(n.get() + 1)); + let (index, mps) = (usize::from(*cx & 0x7F), *cx >> 7 == 1); + let Some(&(qe, nmps, nlps, switch)) = STATES.get(index) else { return }; + self.a -= qe; + if d == mps { + // CODEMPS. + if self.a & 0x8000 != 0 { + self.c += qe; + return; + } + if self.a < qe { + self.a = qe; + } else { + self.c += qe; + } + *cx = nmps | (*cx & 0x80); + } else { + // CODELPS. + if self.a < qe { + self.c += qe; + } else { + self.a = qe; + } + let mps = mps != switch; + *cx = nlps | (u8::from(mps) << 7); + } + self.renorm(); + } + + /// RENORME (Figure E.8). + fn renorm(&mut self) { + loop { + self.a <<= 1; + self.c <<= 1; + self.ct -= 1; + if self.ct == 0 { + self.byte_out(); + } + if self.a & 0x8000 != 0 { + break; + } + } + } + + /// BYTEOUT (Figure E.9), with bit stuffing after 0xFF and carry into the byte before. + fn byte_out(&mut self) { + if self.b() == 0xFF { + self.stuffed(); + } else if self.c < 0x800_0000 { + self.plain(); + } else { + let b = self.b().wrapping_add(1); + if let Some(last) = self.out.last_mut() { + *last = b; + } + if b == 0xFF { + self.c &= 0x7FF_FFFF; + self.stuffed(); + } else { + self.plain(); + } + } + } + + fn plain(&mut self) { + self.out.push(((self.c >> 19) & 0xFF) as u8); + self.c &= 0x7_FFFF; + self.ct = 8; + } + + fn stuffed(&mut self) { + self.out.push(((self.c >> 20) & 0xFF) as u8); + self.c &= 0xF_FFFF; + self.ct = 7; + } + + /// FLUSH (Figures E.11 and E.12): the final bytes, then the 0xFF 0xAC marker. + pub(crate) fn finish(mut self) -> Vec { + let top = self.c + self.a; + self.c |= 0xFFFF; + if self.c >= top { + self.c -= 0x8000; + } + self.c <<= self.ct; + self.byte_out(); + self.c <<= self.ct; + self.byte_out(); + if self.b() != 0xFF { + self.out.push(0xFF); + } + self.out.push(0xAC); + self.out.split_off(1) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Annex H.2's test sequence: 256 decisions in one context give these 30 bytes. + #[test] + fn the_annex_h_test_sequence_encodes_to_its_bytes() { + let decisions = [ + 0x00, 0x02, 0x00, 0x51, 0x00, 0x00, 0x00, 0xC0, 0x03, 0x52, 0x87, 0x2A, 0xAA, 0xAA, 0xAA, 0xAA, 0x82, 0xC0, 0x20, 0x00, 0xFC, 0xD7, 0x9E, + 0xF6, 0xBF, 0x7F, 0xED, 0x90, 0x4F, 0x46, 0xA3, 0xBF, + ]; + let mut mq = MqEncoder::new(); + let mut cx = 0; + for byte in decisions { + for bit in (0..8).rev() { + mq.encode(&mut cx, byte >> bit & 1 == 1); + } + } + let expected = [ + 0x84, 0xC7, 0x3B, 0xFC, 0xE1, 0xA1, 0x43, 0x04, 0x02, 0x20, 0x00, 0x00, 0x41, 0x0D, 0xBB, 0x86, 0xF4, 0x31, 0x7F, 0xFF, 0x88, 0xFF, 0x37, + 0x47, 0x1A, 0xDB, 0x6A, 0xDF, 0xFF, 0xAC, + ]; + assert_eq!(mq.finish(), expected); + } +} diff --git a/crates/fonts/README.md b/crates/fonts/README.md index 36b305bd9d..b20b930a91 100644 --- a/crates/fonts/README.md +++ b/crates/fonts/README.md @@ -72,7 +72,7 @@ needed. wasm32 builds embed only BIZ UDPGothic Regular and any `Arab`, `Telu`, `Hebr` or `Thai` face, to keep the web build small: the web build currently has no Chinese face, so Chinese there still shows the replacement glyph. Font files are never -committed here (`AGENTS.md` §1.4; team members: [craftrules `standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md), internal). +committed here (`AGENTS.md` §1.4). The full font subsystem (parsing, shaping, subsetting and embedding) arrives with M2.2/M7. diff --git a/crates/fonts/src/pdf.rs b/crates/fonts/src/pdf.rs index 6e7ac7a8c2..240b1c4d55 100644 --- a/crates/fonts/src/pdf.rs +++ b/crates/fonts/src/pdf.rs @@ -1330,818 +1330,7 @@ fn identity_to_unicode() -> Vec { } #[cfg(test)] -mod tests { - use pdfcraft_cos::{Dict, Document, Object, Stream}; - - use super::*; - - /// Span records read while `f` runs. - fn span_reads(f: impl FnOnce()) -> usize { - SPAN_READS.with(|c| c.set(0)); - f(); - SPAN_READS.with(|c| c.get()) - } - - /// A composite font with `encoding` as its CMap, `w` as its widths and `to_unicode`. - fn composite(doc: &mut Document, encoding: &str, w: Vec, to_unicode: &str) -> Dict { - let mut desc = Dict::new(); - desc.set(b"Subtype".to_vec(), Object::name("CIDFontType2")); - desc.set(b"W".to_vec(), Object::Array(w)); - let enc = Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), encoding.as_bytes())))); - let tu = Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), to_unicode.as_bytes())))); - font( - doc, - vec![ - ("Subtype", Object::name("Type0")), - ("Encoding", enc), - ("DescendantFonts", Object::Array(vec![Object::Dict(desc)])), - ("ToUnicode", tu), - ], - ) - } - - /// Many `/W` ranges that leave a code out cost a glyph of it a binary search, not a scan of them all. - #[test] - fn widths_of_a_code_no_range_covers_take_few_steps() { - let mut doc = Document::new_empty(); - let w: Vec = (1..=10_000i64).flat_map(|k| [Object::Int(2 * k), Object::Int(2 * k), Object::Int(500)]).collect(); - let f = composite(&mut doc, "", w, ""); - let m = Metrics::from_dict(&doc, &f); - assert_eq!(m.width(4), 0.5); - let reads = span_reads(|| { - for _ in 0..1000 { - assert_eq!(m.width(3), 1.0); - } - }); - assert!(reads <= 1000 * 64, "{reads} span reads"); - } - - /// Where spans overlap, the first-declared one wins, as a scan in declaration order finds it. - #[test] - fn overlapping_spans_resolve_to_the_first_declared() { - let spans = Spans::new(vec![(0, 10, 'a'), (5, 20, 'b'), (3, 3, 'c'), (30, 25, 'd'), (u32::MAX, u32::MAX, 'e')]); - let found = |code| spans.find(code).map(|s| s.2); - let want = [(0, Some('a')), (3, Some('a')), (10, Some('a')), (11, Some('b')), (20, Some('b')), (21, None), (27, None), (u32::MAX, Some('e'))]; - for (code, value) in want { - assert_eq!(found(code), value, "code {code}"); - } - } - - /// Checking the glyph of every explicit ToUnicode entry looks its code up once in the CID map. - #[test] - fn many_unicode_entries_look_up_their_cids_in_few_steps() { - let n = 2000; - let cidchars: String = (0..n).map(|k| format!("<{:04X}> {} ", 0x8000 + k, k + 1)).collect(); - let encoding = format!("1 begincodespacerange <0000> endcodespacerange {n} begincidchar {cidchars} endcidchar"); - let bfchars: String = (0..n).map(|k| format!("<{:04X}> <{:04X}> ", 0x1000 + k, 0x4E00 + k)).collect(); - let to_unicode = format!("{n} beginbfchar {bfchars} endbfchar"); - let mut doc = Document::new_empty(); - let f = composite(&mut doc, &encoding, Vec::new(), &to_unicode); - let reads = span_reads(|| { - Metrics::from_dict(&doc, &f); - }); - assert!(reads <= n * 64, "{reads} span reads"); - } - - /// Every glyph of a sequential range builds its text from the destination, so a long one is cut - /// to the CMap limit once, when it is read. - #[test] - fn unicode_destinations_are_cut_to_the_cmap_limit() { - let long = "0041".repeat(4096); - let cmap = format!("2 beginbfrange <0010> <0020> <{long}> <0030> <0030> [<{long}>] endbfrange 1 beginbfchar <0040> <{long}> endbfchar"); - let mut u = Unicode::default(); - parse_to_unicode(cmap.as_bytes(), &mut u); - for code in [0x10, 0x20, 0x30, 0x40] { - let text = u.text(code).unwrap_or_default(); - assert!(!text.is_empty() && text.len() <= MAX_DESTINATION_UNITS * 3, "code {code:#x}: {} bytes", text.len()); - } - } - - /// A font whose ranges cover a few low codes keeps tables that size, not a fixed 64k-entry one. - #[test] - fn low_code_tables_end_with_the_last_covered_code() { - // One range over codes 0x20..=0x21, then nothing. - let cover = [(0x20u64, Some(0usize)), (0x22, None)]; - let owner = super::owners_below_64k(&cover); - assert_eq!(owner.len(), 0x22); - assert_eq!((owner[0x1F], owner[0x20], owner[0x21]), (u32::MAX, 0, 0)); - assert!(super::owners_below_64k(&[(0x2_0000, Some(0))]).is_empty(), "no code below 65 536"); - let bits = super::bit_set_below_64k([3u32, 130, 0x2_0000].into_iter()); - assert_eq!(bits.len(), 3); - assert_eq!((bits[0], bits[2]), (1 << 3, 1 << 2)); - assert!(super::bit_set_below_64k([0x1_0000u32].into_iter()).is_empty()); - } - - fn font(doc: &mut Document, entries: Vec<(&str, Object)>) -> Dict { - let mut d = Dict::new(); - d.set(b"Type".to_vec(), Object::name("Font")); - for (k, v) in entries { - d.set(k.as_bytes().to_vec(), v); - } - let _ = doc; - d - } - - #[test] - fn embedded_editor_fonts_are_validated_without_installing_or_exporting() { - use pdfcraft_cos::Stream; - let mut doc = Document::new_empty(); - let bytes = include_bytes!("../../../assets/fonts/Inter-Regular.ttf"); - let program = doc.add(Object::Stream(Stream::flate(Dict::new(), bytes))); - let mut descriptor = Dict::new(); - descriptor.set(b"FontFile2".to_vec(), Object::Ref(program)); - let simple = font(&mut doc, vec![("FontDescriptor", Object::Dict(descriptor.clone()))]); - assert_eq!(embedded_sfnt(&doc, &simple).unwrap(), bytes); - let composite = font(&mut doc, vec![("DescendantFonts", Object::Array(vec![Object::Dict(simple.clone())]))]); - assert_eq!(embedded_sfnt(&doc, &composite).unwrap(), bytes); - let broken = doc.add(Object::Stream(Stream::flate(Dict::new(), b"broken font"))); - descriptor.set(b"FontFile2".to_vec(), Object::Ref(broken)); - let broken = font(&mut doc, vec![("FontDescriptor", Object::Dict(descriptor))]); - assert!(embedded_sfnt(&doc, &broken).is_none()); - assert!(embedded_sfnt(&doc, &Dict::new()).is_none()); - } - - /// #2130: with no `/Widths`, an embedded program's advances win over a guess from the name - /// ("Mono" would mean Courier, 600 units for every glyph). - #[test] - fn a_font_without_widths_measures_with_its_embedded_program() { - use pdfcraft_cos::Stream; - use skrifa::MetadataProvider as _; - let mut doc = Document::new_empty(); - let bytes = include_bytes!("../../../assets/fonts/Inter-Regular.ttf"); - let face = skrifa::FontRef::new(bytes).unwrap(); - let (size, location) = (skrifa::instance::Size::unscaled(), skrifa::instance::LocationRef::default()); - let upem = f64::from(face.metrics(size, location).units_per_em); - let advance = |c: char| f64::from(face.glyph_metrics(size, location).advance_width(face.charmap().map(c).unwrap()).unwrap()) / upem; - let program = doc.add(Object::Stream(Stream::flate(Dict::new(), bytes))); - let mut descriptor = Dict::new(); - descriptor.set(b"FontFile2".to_vec(), Object::Ref(program)); - let f = font( - &mut doc, - vec![ - ("Subtype", Object::name("TrueType")), - ("BaseFont", Object::name("HouseMono")), - ("Encoding", Object::name("WinAnsiEncoding")), - ("FontDescriptor", Object::Dict(descriptor)), - ], - ); - let m = Metrics::from_dict(&doc, &f); - for c in ['i', 'l', 'W', ' '] { - assert!((m.width(u32::from(c)) - advance(c)).abs() < 1e-9, "{c}: {} vs {}", m.width(u32::from(c)), advance(c)); - } - assert!(m.width(u32::from('i')) < 0.4, "not Courier's 0.6"); - } - - #[test] - fn mac_subset_cmap_uses_pdf_unicode_without_changing_source_program() { - use skrifa::{MetadataProvider as _, raw::TableProvider as _}; - let original = include_bytes!("../../../assets/fonts/Inter-Regular.ttf"); - let face = skrifa::FontRef::new(original).unwrap(); - let expected = face.charmap().map('Ω').unwrap(); - let mut subset = original.to_vec(); - let count = u16::from_be_bytes(subset[4..6].try_into().unwrap()) as usize; - let entry = (0..count).map(|i| 12 + i * 16).find(|i| &subset[*i..*i + 4] == b"cmap").unwrap(); - let mut cmap = vec![0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 12]; - cmap.extend_from_slice(&[0, 6, 0, 12, 0, 0, 0, 65, 0, 1]); - cmap.extend_from_slice(&u16::try_from(expected.to_u32()).unwrap().to_be_bytes()); - let offset = subset.len() as u32; - subset[entry + 8..entry + 12].copy_from_slice(&offset.to_be_bytes()); - subset[entry + 12..entry + 16].copy_from_slice(&(cmap.len() as u32).to_be_bytes()); - subset.extend_from_slice(&cmap); - let face = skrifa::FontRef::new(&subset).unwrap(); - assert!(!face.charmap().has_map(), "egui cannot directly use Mac format 6"); - assert_eq!(face.cmap().unwrap().map_codepoint(65_u32), Some(expected)); - let mut doc = Document::new_empty(); - let program = doc.add(Object::Stream(Stream::flate(Dict::new(), &subset))); - let mut descriptor = Dict::new(); - descriptor.set(b"FontFile2".to_vec(), Object::Ref(program)); - let unicode = doc.add(Object::Stream(Stream::flate(Dict::new(), b"1 beginbfchar <41> <03A9> endbfchar"))); - let source = font(&mut doc, vec![("FontDescriptor", Object::Dict(descriptor)), ("ToUnicode", Object::Ref(unicode))]); - let editor = embedded_sfnt(&doc, &source).unwrap(); - let face = skrifa::FontRef::new(&editor).unwrap(); - assert_eq!(face.charmap().map('Ω'), Some(expected), "Unicode selects the original glyph"); - let Object::Stream(source) = doc.resolve(&Object::Ref(program)).as_ref().clone() else { - panic!("source stream"); - }; - assert_eq!(source.decoded().unwrap(), subset, "only an in-memory editor copy is adapted"); - let mut broken = vec![0; 16]; - assert!(append_editor_cmap(&mut broken, &std::collections::BTreeMap::from([(65, 1)])).is_none()); - } - - fn metrics_with_flags(flags: Option) -> Metrics { - let mut doc = Document::new_empty(); - let mut descriptor = Dict::new(); - if let Some(flags) = flags { - descriptor.set(b"Flags".to_vec(), flags); - } - let font = font(&mut doc, vec![("Subtype", Object::name("TrueType")), ("FontDescriptor", Object::Dict(descriptor))]); - Metrics::from_dict(&doc, &font) - } - - #[test] - fn font_descriptor_flags_stay_within_the_u32_domain() { - let valid = metrics_with_flags(Some(Object::Int(64 | 262_144))); - assert!(valid.italic && valid.bold); - - for flags in [Some(Object::Int(-1)), Some(Object::Int(4_294_967_296)), Some(Object::Bool(true)), None] { - let metrics = metrics_with_flags(flags); - assert!(!metrics.italic && !metrics.bold); - } - } - - #[test] - fn type3_descriptor_names_the_font_without_changing_its_metrics() { - let mut doc = Document::new_empty(); - let mut f = font( - &mut doc, - vec![ - ("Subtype", Object::name("Type3")), - ( - "FontMatrix", - Object::Array(vec![Object::Real(0.001), Object::Int(0), Object::Int(0), Object::Real(0.001), Object::Int(0), Object::Int(0)]), - ), - ("FontBBox", Object::Array(vec![Object::Int(0), Object::Int(-300), Object::Int(1000), Object::Int(1000)])), - ], - ); - let before = Metrics::from_dict(&doc, &f); - let mut descriptor = Dict::new(); - descriptor.set(b"Type".to_vec(), Object::name("FontDescriptor")); - descriptor.set(b"FontName".to_vec(), Object::name("ExampleMincho-Regular")); - descriptor.set(b"Flags".to_vec(), Object::Int(6)); - descriptor.set(b"ItalicAngle".to_vec(), Object::Int(0)); - let reference = doc.add(Object::Dict(descriptor)); - f.set(b"FontDescriptor".to_vec(), Object::Ref(reference)); - let after = Metrics::from_dict(&doc, &f); - assert_eq!(after.base_font, "ExampleMincho-Regular"); - assert_eq!((after.ascent, after.descent, after.scale), (before.ascent, before.descent, before.scale)); - assert!(!after.bold && !after.italic && !after.subset); - f.set(b"BaseFont".to_vec(), Object::name("ExplicitName")); - assert_eq!(Metrics::from_dict(&doc, &f).base_font, "ExplicitName", "an explicit BaseFont takes precedence"); - } - - #[test] - fn simple_fonts_decode_and_encode_through_their_encoding() { - let mut doc = Document::new_empty(); - let f = font( - &mut doc, - vec![("Subtype", Object::name("Type1")), ("BaseFont", Object::name("Helvetica")), ("Encoding", Object::name("WinAnsiEncoding"))], - ); - let m = Metrics::from_dict(&doc, &f); - assert_eq!(m.decode(b"Caf\xe9 \x80 \x93x\x94"), "Café € “x”"); - assert_eq!(m.encode("Café €").as_deref(), Some(&b"Caf\xe9 \x80"[..])); - assert_eq!(m.encode("Ω"), None, "not in WinAnsi"); - // Differences rename codes by glyph name. - let mut enc = Dict::new(); - enc.set(b"Differences".to_vec(), Object::Array(vec![Object::Int(65), Object::name("eacute"), Object::name("uni03A9")])); - let f = font(&mut doc, vec![("Subtype", Object::name("Type1")), ("BaseFont", Object::name("Custom")), ("Encoding", Object::Dict(enc))]); - let m = Metrics::from_dict(&doc, &f); - assert_eq!(m.decode(b"AB"), "éΩ"); - assert_eq!(m.encode("Ω").as_deref(), Some(&b"B"[..])); - } - - /// A standard-14 font with no `/Widths` is measured by the glyph each code draws, not by the - /// glyph WinAnsi gives that code (#2894); `/Widths`, when present, still go by code. - #[test] - fn standard_fonts_without_widths_measure_the_glyph_their_differences_name() { - let mut doc = Document::new_empty(); - let mut enc = Dict::new(); - enc.set(b"BaseEncoding".to_vec(), Object::name("WinAnsiEncoding")); - let diffs = - vec![Object::Int(65), Object::name("W"), Object::Int(87), Object::name("A"), Object::name("uni03A9"), Object::name("not.a.glyph")]; - enc.set(b"Differences".to_vec(), Object::Array(diffs)); - let f = font( - &mut doc, - vec![("Subtype", Object::name("Type1")), ("BaseFont", Object::name("Helvetica")), ("Encoding", Object::Dict(enc.clone()))], - ); - let m = Metrics::from_dict(&doc, &f); - assert_eq!((m.width(65), m.width(87)), (0.944, 0.667), "A and W swap their widths"); - // Omega has no WinAnsi code and the last name reads as nothing: both keep their code's width. - assert_eq!((m.width(88), m.width(89)), (0.667, 0.667)); - let f = font( - &mut doc, - vec![ - ("Subtype", Object::name("Type1")), - ("BaseFont", Object::name("Helvetica")), - ("FirstChar", Object::Int(87)), - ("Widths", Object::Array(vec![Object::Int(500)])), - ("Encoding", Object::Dict(enc)), - ], - ); - assert_eq!(Metrics::from_dict(&doc, &f).width(87), 0.5, "/Widths go by code"); - } - - #[test] - fn subset_fonts_only_encode_the_glyphs_they_have() { - let mut doc = Document::new_empty(); - // Widths for a, b only (97, 98); c (99) is 0. - let widths = Object::Array(vec![Object::Int(500), Object::Int(520), Object::Int(0)]); - let f = font( - &mut doc, - vec![ - ("Subtype", Object::name("TrueType")), - ("BaseFont", Object::name("ABCDEF+Arial")), - ("FirstChar", Object::Int(97)), - ("Widths", widths), - ("Encoding", Object::name("WinAnsiEncoding")), - ], - ); - let m = Metrics::from_dict(&doc, &f); - assert!(m.subset); - assert_eq!(m.encode("ab").as_deref(), Some(&b"ab"[..])); - assert_eq!(m.encode("abc"), None); - } - - #[test] - fn cmap_streams_decode_within_a_size_cap() { - // A valid CMap padded past the cap (a small Flate stream of whitespace) is not decoded whole. - let to_unicode = b"1 begincodespacerange <0000> endcodespacerange 1 beginbfrange <0024> <0026> <0041> endbfrange"; - let encoding = b"1 begincodespacerange <0000> endcodespacerange 1 begincidrange <0000> 0 endcidrange"; - for padded in [false, true] { - let mut doc = Document::new_empty(); - let mut stream = |cmap: &[u8]| { - let mut data = cmap.to_vec(); - if padded { - data.resize(MAX_CMAP_BYTES + 1, b' '); - } - Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), &data)))) - }; - let (tu, enc) = (stream(to_unicode), stream(encoding)); - let mut desc = Dict::new(); - desc.set(b"Subtype".to_vec(), Object::name("CIDFontType2")); - let f = font( - &mut doc, - vec![ - ("Subtype", Object::name("Type0")), - ("Encoding", enc), - ("DescendantFonts", Object::Array(vec![Object::Dict(desc)])), - ("ToUnicode", tu), - ], - ); - let m = Metrics::from_dict(&doc, &f); - assert_eq!(m.decode(&[0, 0x24]) == "A", !padded, "ToUnicode, padded: {padded}"); - assert_eq!(matches!(m.codes, Codes::Ranges(_)), !padded, "Encoding, padded: {padded}"); - } - } - - #[test] - fn composite_fonts_use_to_unicode() { - let mut doc = Document::new_empty(); - let cmap = b"/CIDInit /ProcSet findresource begin 1 begincodespacerange <0000> endcodespacerange \ - 2 beginbfchar <0003> <0020> <0010> <00E9> endbfchar 1 beginbfrange <0024> <0026> <0041> endbfrange \ - 1 beginbfrange <0030> <0031> [<0048> <0069>] endbfrange end"; - let tu = doc.add(Object::Stream(Stream::from_raw(Dict::new(), cmap.to_vec()))); - let mut desc = Dict::new(); - desc.set(b"Subtype".to_vec(), Object::name("CIDFontType2")); - desc.set( - b"W".to_vec(), - Object::Array(vec![ - Object::Int(3), - Object::Array(vec![Object::Int(250)]), - Object::Int(16), - Object::Int(16), - Object::Int(500), - Object::Int(36), - Object::Int(49), - Object::Int(600), - ]), - ); - let f = font( - &mut doc, - vec![ - ("Subtype", Object::name("Type0")), - ("BaseFont", Object::name("QWERTY+Noto")), - ("Encoding", Object::name("Identity-H")), - ("DescendantFonts", Object::Array(vec![Object::Dict(desc)])), - ("ToUnicode", Object::Ref(tu)), - ], - ); - let m = Metrics::from_dict(&doc, &f); - assert_eq!(m.decode(&[0, 0x24, 0, 0x25, 0, 3, 0, 0x30, 0, 0x31, 0, 0x10]), "AB Hié"); - assert_eq!(m.encode("Hi A").as_deref(), Some(&[0, 0x30, 0, 0x31, 0, 3, 0, 0x24][..])); - assert_eq!(m.encode("Z"), None); - } - - /// Word's Type 0 subsets map CID 0 as well as the real space glyph to U+0020 and give CID 0 a - /// width; CID 0 is `.notdef`, so a typed space takes the real one (#3223). - #[test] - fn composite_fonts_never_encode_with_cid_0() { - let mut doc = Document::new_empty(); - let cmap = b"/CIDInit /ProcSet findresource begin 1 begincodespacerange <0000> endcodespacerange \ - 1 beginbfrange <0000> <0002> [<0020> <004D> <00E1>] endbfrange \ - 1 beginbfrange <0005> <0007> [<0065> <0072> <0020>] endbfrange end"; - let tu = doc.add(Object::Stream(Stream::from_raw(Dict::new(), cmap.to_vec()))); - let mut desc = Dict::new(); - desc.set(b"Subtype".to_vec(), Object::name("CIDFontType2")); - let widths = [889, 444, 389, 278, 444, 333, 250].map(Object::Int).to_vec(); - desc.set(b"W".to_vec(), Object::Array(vec![Object::Int(0), Object::Array(vec![Object::Int(778)]), Object::Int(1), Object::Array(widths)])); - let f = font( - &mut doc, - vec![ - ("Subtype", Object::name("Type0")), - ("BaseFont", Object::name("BCDEEE+TimesNewRomanPSMT")), - ("Encoding", Object::name("Identity-H")), - ("DescendantFonts", Object::Array(vec![Object::Dict(desc)])), - ("ToUnicode", Object::Ref(tu)), - ], - ); - let m = Metrics::from_dict(&doc, &f); - assert_eq!(m.encode("Me er").as_deref(), Some(&[0, 1, 0, 5, 0, 7, 0, 5, 0, 6][..])); - assert_eq!(m.decode(&[0, 1, 0, 7, 0, 2]), "M á"); - } - - /// WSCAD writes `/ToUnicode /Identity-H` (a name, not a CMap stream) on composite fonts whose - /// codes are UTF-16 code units: the codes read as the same Unicode, as in Acrobat. Controls - /// and lone surrogates map to nothing, and a simple font's name is not a ToUnicode map. - #[test] - fn identity_to_unicode_names_read_codes_as_unicode() { - let doc = Document::new_empty(); - for name in ["Identity-H", "Identity-V"] { - let f = font( - &mut Document::new_empty(), - vec![ - ("Subtype", Object::name("Type0")), - ("BaseFont", Object::name("RobotoMono-Regular")), - ("Encoding", Object::name("Identity-H")), - ("DescendantFonts", Object::Array(vec![Object::Dict(Dict::new())])), - ("ToUnicode", Object::name(name)), - ], - ); - let m = Metrics::from_dict(&doc, &f); - assert_eq!(m.decode(&[0, b'Z', 0, 0xFC, 0, b'r', 0x20, 0xAC, 0x4E, 0x2D]), "Zür€中", "{name}"); - assert_eq!(m.text_of(0x0001), None); - assert_eq!(m.text_of(0x007F), None, "DEL is a control"); - assert_eq!(m.text_of(0x0085), None, "a C1 control"); - assert_eq!(m.text_of(0x00A0).as_deref(), Some("\u{A0}"), "the range resumes after C1"); - assert_eq!(m.text_of(0x007E).as_deref(), Some("~")); - assert_eq!(m.text_of(0xD800), None); - assert_eq!(m.text_of(0xFFFD).as_deref(), Some("\u{FFFD}")); - } - let simple = font( - &mut Document::new_empty(), - vec![("Subtype", Object::name("TrueType")), ("BaseFont", Object::name("Arial")), ("ToUnicode", Object::name("Identity-H"))], - ); - let m = Metrics::from_dict(&doc, &simple); - assert_eq!(m.text_of(0x41).as_deref(), Some("A"), "the encoding still applies"); - } - - /// Chrome's subsets leave the glyphs of `/DW` width out of `/W` (#1443): "s" (CID 0x56) is - /// still in the font, but nothing is when the font gives no default width to point at. - #[test] - fn composite_subset_glyphs_of_the_default_width_are_present() { - let metrics = |dw: bool| { - let mut doc = Document::new_empty(); - let cmap = b"/CIDInit /ProcSet findresource begin 1 begincodespacerange <0000> endcodespacerange \ - 1 beginbfrange <0055> <0057> <0072> endbfrange end"; - let tu = doc.add(Object::Stream(Stream::from_raw(Dict::new(), cmap.to_vec()))); - let mut desc = Dict::new(); - desc.set(b"Subtype".to_vec(), Object::name("CIDFontType2")); - if dw { - desc.set(b"DW".to_vec(), Object::Int(500)); - } - desc.set( - b"W".to_vec(), - Object::Array(vec![ - Object::Int(0x55), - Object::Array(vec![Object::Int(333)]), - Object::Int(0x57), - Object::Array(vec![Object::Int(277)]), - ]), - ); - let f = font( - &mut doc, - vec![ - ("Subtype", Object::name("Type0")), - ("BaseFont", Object::name("AAAAAA+LiberationSans")), - ("Encoding", Object::name("Identity-H")), - ("DescendantFonts", Object::Array(vec![Object::Dict(desc)])), - ("ToUnicode", Object::Ref(tu)), - ], - ); - Metrics::from_dict(&doc, &f) - }; - let chrome = metrics(true); - assert_eq!(chrome.encode("rst").as_deref(), Some(&[0, 0x55, 0, 0x56, 0, 0x57][..])); - assert_eq!(chrome.width(0x56), 0.5); - assert_eq!(chrome.encode("x"), None, "a character the ToUnicode map doesn't give"); - assert_eq!(metrics(false).encode("s"), None); - } - - /// A `/W` run that starts at the last CID stops there instead of overflowing onto CID 0. - #[test] - fn composite_width_run_past_the_last_cid_is_dropped() { - let widths = |first: i64| { - let mut doc = Document::new_empty(); - let mut desc = Dict::new(); - desc.set(b"Subtype".to_vec(), Object::name("CIDFontType2")); - desc.set(b"DW".to_vec(), Object::Int(1000)); - desc.set(b"W".to_vec(), Object::Array(vec![Object::Int(first), Object::Array(vec![Object::Int(100), Object::Int(200)])])); - let f = font( - &mut doc, - vec![ - ("Subtype", Object::name("Type0")), - ("Encoding", Object::name("Identity-H")), - ("DescendantFonts", Object::Array(vec![Object::Dict(desc)])), - ], - ); - Metrics::from_dict(&doc, &f) - }; - let ordinary = widths(0); - assert_eq!((ordinary.width(0), ordinary.width(1)), (0.1, 0.2)); - let last = widths(i64::from(u32::MAX)); - assert_eq!((last.width(0), last.width(u32::MAX)), (1.0, 0.1)); - } - - #[test] - fn glyph_names() { - assert_eq!(glyph_unicode("quotedblleft"), Some('“')); - assert_eq!(glyph_unicode("uni20AC"), Some('€')); - assert_eq!(glyph_unicode("u1F600"), Some('😀')); - assert_eq!(glyph_unicode("nonsense"), None); - } - - fn to_unicode(cmap: &[u8]) -> HashMap { - let mut u = Unicode::default(); - parse_to_unicode(cmap, &mut u); - let codes: Vec = u.explicit.keys().copied().chain(u.ranges.iter().flat_map(|r| r.lo..=r.hi)).collect(); - codes.into_iter().filter_map(|c| u.text(c).map(|t| (c, t.into_owned()))).collect() - } - - #[test] - fn cmap_tokens_need_no_space_between_them() { - // Hex strings, brackets and operators may touch; comments and literal strings are skipped. - let cmap = b"/CIDSystemInfo << /Registry (Adobe (x) \\) beginbfchar) >> def % beginbfchar <0001> <0058>\n\ - 2 beginbfchar<0003><0020><0010><00 E9>endbfchar \ - 1 beginbfrange<0024><0026><0041>endbfrange 1 beginbfrange<0030><0031>[<0048><0069>]endbfrange"; - let map = to_unicode(cmap); - let mut got: Vec<_> = map.iter().map(|(c, u)| (*c, u.as_str())).collect(); - got.sort_unstable(); - assert_eq!(got, [(3, " "), (0x10, "é"), (0x24, "A"), (0x25, "B"), (0x26, "C"), (0x30, "H"), (0x31, "i")]); - let (spaces, cids) = parse_cmap(b"1 begincodespacerange<00>endcodespacerange 1 begincidrange<20><7E>1 endcidrange"); - assert_eq!(spaces, [(1, vec![0], vec![0xff])]); - assert_eq!(cids, [(0x20, 0x7e, 1)]); - } - - #[test] - fn malformed_cmaps_never_panic() { - // Non-ASCII bytes inside hex strings once split a UTF-8 character and panicked. - for cmap in [ - "1 beginbfchar <0é0> <0041> <0042> endbfchar".as_bytes(), - "1 beginbfrange <0é0> <00é> <0041> endbfrange".as_bytes(), - b"1 beginbfchar <0001> <004", - b"1 beginbfchar <123> <0041> <0002> <0003> endbfchar", - b"1 beginbfrange <0001> [<0041>] endbfrange", - b"1 beginbfrange <0001> <0002> [<0041> (", - b"1 beginbfrange <0001>", - b"\xff\xfe beginbfchar \x80<\xc3\xa9> <0041> endbfchar %", - ] { - to_unicode(cmap); - parse_cmap(cmap); - } - // Code counters at the top of the range stop instead of overflowing. - let map = to_unicode(b"1 beginbfrange [<0041> <0042> <0043>] endbfrange"); - assert_eq!(map.len(), 2); - assert_eq!(map.get(&u32::MAX).map(String::as_str), Some("B")); - // A malformed array entry keeps its place, so the next one maps the next code. - let map = to_unicode(b"1 beginbfrange <0001> <0003> [<0041> <0043>] endbfrange"); - assert_eq!((map.get(&2), map.get(&3).map(String::as_str)), (None, Some("C"))); - let mut m = Metrics::fallback(); - m.cid_map = Spans::new(parse_cmap(b"1 begincidrange <0000> 4294967295 endcidrange").1); - assert_eq!(m.cid(5), u32::MAX); - let mut doc = Document::new_empty(); - let mut enc = Dict::new(); - enc.set(b"Differences".to_vec(), Object::Array(vec![Object::Int(i64::MAX), Object::name("A"), Object::name("B")])); - let f = font(&mut doc, vec![("Subtype", Object::name("Type1")), ("Encoding", Object::Dict(enc))]); - assert_eq!(unicode_map(&doc, &f, false).text(u32::MAX).as_deref(), Some("B")); - } - - #[test] - fn sequential_ranges_past_the_entry_cap_still_map() { - // 17 ranges of 65 536 codes: 1 114 112 codes from 0.4 KB of CMap, more than MAX_CMAP_ENTRIES - // (the first 16 ranges use up the whole cap). Each range maps its codes upward from 'A', so - // the last code of the last range, 0x100019, is 'A' + 0x19 = 'Z'. - let mut cmap = b"17 beginbfrange ".to_vec(); - for k in 0..17u32 { - cmap.extend(format!("<{:08X}> <{:08X}> <0041> ", k << 16, (k << 16) | 0xffff).bytes()); - } - cmap.extend(b"endbfrange"); - let mut doc = Document::new_empty(); - let tu = doc.add(Object::Stream(Stream::from_raw(Dict::new(), cmap))); - let f = font( - &mut doc, - vec![ - ("Subtype", Object::name("Type0")), - ("BaseFont", Object::name("Wide")), - ("Encoding", Object::name("Identity-H")), - ("ToUnicode", Object::Ref(tu)), - ], - ); - let m = Metrics::from_dict(&doc, &f); - assert_eq!(m.text_of(0x0010_0019).as_deref(), Some("Z")); - } - - #[test] - fn a_cmap_adds_at_most_max_entries() { - // 200 ranges of 65 536 codes would be 13 million codes from 4 KB of stream: 200 records. - let mut cmap = b"200 beginbfrange ".to_vec(); - for k in 0..200u32 { - cmap.extend(format!("<{:08X}> <{:08X}> <0041> ", k << 16, (k << 16) | 0xffff).bytes()); - } - cmap.extend(b"endbfrange"); - let mut u = Unicode::default(); - parse_to_unicode(&cmap, &mut u); - assert_eq!((u.ranges.len(), u.explicit.len()), (200, 0)); - // Explicit entries are what the budget counts: 250 of the 300 here are kept. - let mut many = b"300 beginbfchar ".to_vec(); - for k in 0..300u32 { - many.extend(format!("<{k:04X}> <{:04X}> ", 0x4E00 + k).bytes()); - } - many.extend(b"endbfchar"); - let mut u = Unicode::default(); - parse_to_unicode_within(&many, &mut u, 250); - assert_eq!((u.explicit.len(), u.text(249).is_some(), u.text(250).is_some()), (250, true, false)); - let mut many = b"begincidrange ".to_vec(); - for k in 0..MAX_CMAP_RANGES + 10 { - many.extend(format!("<{k:08X}> <{k:08X}> 1 ").bytes()); - } - assert_eq!(parse_cmap(&many).1.len(), MAX_CMAP_RANGES); - } - - #[test] - fn a_range_is_one_record_however_many_codes() { - let mut u = Unicode::default(); - parse_to_unicode(b"1 beginbfrange <0000> <0041> endbfrange", &mut u); - assert_eq!((u.ranges.len(), u.explicit.len()), (1, 0)); - assert_eq!(u.text(0xFFFF).as_deref(), Some("@"), "0x41 + 0xFFFF wraps to U+0040"); - } - - #[test] - fn sequential_ranges_count_against_the_budget() { - // Range k maps codes k and k + 1 and costs two, so a budget of 250 keeps ranges 0 to 124. - let mut cmap = Vec::new(); - for k in 0..300u32 { - cmap.extend(format!("1 beginbfrange <{k:08X}> <{:08X}> <0041> endbfrange ", k + 1).bytes()); - } - let mut u = Unicode::default(); - parse_to_unicode_within(&cmap, &mut u, 250); - assert_eq!((u.ranges.len(), u.text(125).is_some(), u.text(126).is_none()), (125, true, true)); - } - - #[test] - fn explicit_and_range_mappings_override_the_encoding() { - let mut doc = Document::new_empty(); - let cmap = b"1 beginbfchar <41> <0058> endbfchar 1 beginbfrange <42> <43> <0059> endbfrange"; - let tu = doc.add(Object::Stream(Stream::from_raw(Dict::new(), cmap.to_vec()))); - let f = font( - &mut doc, - vec![ - ("Subtype", Object::name("Type1")), - ("BaseFont", Object::name("Custom")), - ("Encoding", Object::name("WinAnsiEncoding")), - ("ToUnicode", Object::Ref(tu)), - ], - ); - let m = Metrics::from_dict(&doc, &f); - let shown: Vec> = (0x41..=0x44).map(|c| m.text_of(c).map(|t| t.into_owned())).collect(); - assert_eq!(shown, [Some("X".to_string()), Some("Y".to_string()), Some("Z".to_string()), Some("D".to_string())]); - } - - /// Deterministic input for the differential test below. - struct Gen(u64); - - impl Gen { - fn below(&mut self, n: u32) -> u32 { - self.0 = self.0.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407); - ((self.0 >> 33) % u64::from(n)) as u32 - } - - /// A destination: a unit from a pool of edge cases, a surrogate pair, or a lone high surrogate. - fn units(&mut self) -> Vec { - const POOL: [u16; 12] = [0x41, 0x42, 0xE9, 0x0, 0xFFFD, 0xFFFE, 0xFFFF, 0x100, 0x3042, 0xD800, 0xDC00, 0xDFFF]; - match self.below(6) { - 0 => vec![0xD835, 0xDC00 + self.below(0x400) as u16], - 1 => vec![0xD800, 0x41], - _ => vec![POOL[self.below(POOL.len() as u32) as usize]], - } - } - } - - fn hex(units: &[u16]) -> String { - units.iter().map(|u| format!("{u:04X}")).collect() - } - - #[test] - fn lookups_match_the_expanded_mappings() { - // Random ToUnicode CMaps; `expected` is what expanding their entries in order gives. - let mut g = Gen(0x2545_f491_4f6c_dd1d); - for _ in 0..300 { - let mut cmap = String::from("1 begincodespacerange <0000> endcodespacerange "); - let mut expected: HashMap = HashMap::new(); - for _ in 0..1 + g.below(12) { - match g.below(3) { - 0 => { - let (code, u) = (g.below(300), g.units()); - cmap += &format!("1 beginbfchar <{code:04X}> <{}> endbfchar ", hex(&u)); - expected.insert(code, String::from_utf16_lossy(&u)); - } - 1 => { - let (lo, width, u) = (g.below(300), g.below(13), g.units()); - cmap += &format!("1 beginbfrange <{lo:04X}> <{:04X}> <{}> endbfrange ", lo + width, hex(&u)); - for k in 0..=width { - let mut v = u.clone(); - if let Some(last) = v.last_mut() { - *last = last.wrapping_add(k as u16); - } - expected.insert(lo + k, String::from_utf16_lossy(&v)); - } - } - _ => { - let (lo, width) = (g.below(300), g.below(5)); - let dests: Vec> = (0..=width).map(|_| g.units()).collect(); - let list: Vec = dests.iter().map(|d| format!("<{}>", hex(d))).collect(); - cmap += &format!("1 beginbfrange <{lo:04X}> <{:04X}> [{}] endbfrange ", lo + width, list.join(" ")); - for (k, d) in dests.iter().enumerate() { - expected.insert(lo + k as u32, String::from_utf16_lossy(d)); - } - } - } - } - let mut doc = Document::new_empty(); - let tu = doc.add(Object::Stream(Stream::from_raw(Dict::new(), cmap.clone().into_bytes()))); - let f = font( - &mut doc, - vec![ - ("Subtype", Object::name("Type0")), - ("BaseFont", Object::name("Gen")), - ("Encoding", Object::name("Identity-H")), - ("ToUnicode", Object::Ref(tu)), - ], - ); - let m = Metrics::from_dict(&doc, &f); - for code in 0..=320u32 { - let got = m.text_of(code).map(|t| t.into_owned()); - assert_eq!(got, expected.get(&code).cloned(), "code {code:#06x} in {cmap}"); - } - let mut reverse: HashMap = HashMap::new(); - // Code 0 is CID 0, `.notdef`, which no character is encoded with. - for (&code, text) in expected.iter().filter(|&(&code, _)| code != 0) { - if let Some(c) = only_char(text) { - keep(&mut reverse, c, code); - } - } - let mut chars: Vec = expected.values().flat_map(|t| t.chars()).chain(['A', '\u{FFFD}', '\u{0}', '\u{1D400}']).collect(); - chars.sort_unstable(); - chars.dedup(); - for c in chars { - let want = reverse.get(&c).map(|code| code.to_be_bytes()[2..].to_vec()); - assert_eq!(m.encode(&c.to_string()), want, "char {c:?} in {cmap}"); - } - } - } - - #[test] - fn fffd_lookups_skip_overridden_surrogates() { - // Each record maps every code to itself, so its surrogate offsets decode to U+FFFD. The last - // record overrides the surrogates with letters, so each lookup walks past them to U+FFFD itself. - let mut cmap = "1 beginbfrange <0000> <0000> endbfrange ".repeat(300); - cmap.push_str("1 beginbfrange <0041> endbfrange"); - let mut doc = Document::new_empty(); - let tu = doc.add(Object::Stream(Stream::from_raw(Dict::new(), cmap.into_bytes()))); - let f = font( - &mut doc, - vec![ - ("Subtype", Object::name("Type0")), - ("BaseFont", Object::name("Gen")), - ("Encoding", Object::name("Identity-H")), - ("ToUnicode", Object::Ref(tu)), - ], - ); - let m = Metrics::from_dict(&doc, &f); - assert_eq!(m.encode("\u{FFFD}"), Some(vec![0xFF, 0xFD])); - } - - #[test] - fn hostile_ranges_stay_bounded() { - // A range wider than 65 536 codes is ignored, a reversed one maps nothing, and a maximal - // one-code range maps just its own code. - let mut u = Unicode::default(); - let cmap = b"1 beginbfrange <00000000> <0041> endbfrange \ - 1 beginbfrange <0005> <0001> <0041> endbfrange \ - 1 beginbfrange endbfrange"; - parse_to_unicode(cmap, &mut u); - assert_eq!((u.ranges.len(), u.text(u32::MAX).as_deref()), (0, Some("\u{FFFF}"))); - } - - #[test] - fn low_codes_resolve_to_the_covering_range() { - let mut u = Unicode::default(); - let cmap = b"1 beginbfrange <0005> <0007> <0041> endbfrange 1 beginbfrange <0064> <0065> <0042> endbfrange \ - 1 beginbfrange <0000FFFE> <00010001> <0043> endbfrange"; - parse_to_unicode(cmap, &mut u); - let covered: Vec = (0..0x70).filter(|&c| u.covering(c).is_some()).collect(); - assert_eq!(covered, [5, 6, 7, 100, 101]); - assert_eq!((u.covering(0xFFFF).is_some(), u.covering(0x1_0001).is_some(), u.covering(0x1_0002).is_none()), (true, true, true)); - } -} +mod tests; #[cfg(test)] mod encoding_tests { diff --git a/crates/fonts/src/pdf/tests.rs b/crates/fonts/src/pdf/tests.rs new file mode 100644 index 0000000000..c2acef191c --- /dev/null +++ b/crates/fonts/src/pdf/tests.rs @@ -0,0 +1,797 @@ +use pdfcraft_cos::{Dict, Document, Object, Stream}; + +use super::*; + +/// Span records read while `f` runs. +fn span_reads(f: impl FnOnce()) -> usize { + SPAN_READS.with(|c| c.set(0)); + f(); + SPAN_READS.with(|c| c.get()) +} + +/// A composite font with `encoding` as its CMap, `w` as its widths and `to_unicode`. +fn composite(doc: &mut Document, encoding: &str, w: Vec, to_unicode: &str) -> Dict { + let mut desc = Dict::new(); + desc.set(b"Subtype".to_vec(), Object::name("CIDFontType2")); + desc.set(b"W".to_vec(), Object::Array(w)); + let enc = Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), encoding.as_bytes())))); + let tu = Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), to_unicode.as_bytes())))); + font( + doc, + vec![("Subtype", Object::name("Type0")), ("Encoding", enc), ("DescendantFonts", Object::Array(vec![Object::Dict(desc)])), ("ToUnicode", tu)], + ) +} + +/// Many `/W` ranges that leave a code out cost a glyph of it a binary search, not a scan of them all. +#[test] +fn widths_of_a_code_no_range_covers_take_few_steps() { + let mut doc = Document::new_empty(); + let w: Vec = (1..=10_000i64).flat_map(|k| [Object::Int(2 * k), Object::Int(2 * k), Object::Int(500)]).collect(); + let f = composite(&mut doc, "", w, ""); + let m = Metrics::from_dict(&doc, &f); + assert_eq!(m.width(4), 0.5); + let reads = span_reads(|| { + for _ in 0..1000 { + assert_eq!(m.width(3), 1.0); + } + }); + assert!(reads <= 1000 * 64, "{reads} span reads"); +} + +/// Where spans overlap, the first-declared one wins, as a scan in declaration order finds it. +#[test] +fn overlapping_spans_resolve_to_the_first_declared() { + let spans = Spans::new(vec![(0, 10, 'a'), (5, 20, 'b'), (3, 3, 'c'), (30, 25, 'd'), (u32::MAX, u32::MAX, 'e')]); + let found = |code| spans.find(code).map(|s| s.2); + let want = [(0, Some('a')), (3, Some('a')), (10, Some('a')), (11, Some('b')), (20, Some('b')), (21, None), (27, None), (u32::MAX, Some('e'))]; + for (code, value) in want { + assert_eq!(found(code), value, "code {code}"); + } +} + +/// Checking the glyph of every explicit ToUnicode entry looks its code up once in the CID map. +#[test] +fn many_unicode_entries_look_up_their_cids_in_few_steps() { + let n = 2000; + let cidchars: String = (0..n).map(|k| format!("<{:04X}> {} ", 0x8000 + k, k + 1)).collect(); + let encoding = format!("1 begincodespacerange <0000> endcodespacerange {n} begincidchar {cidchars} endcidchar"); + let bfchars: String = (0..n).map(|k| format!("<{:04X}> <{:04X}> ", 0x1000 + k, 0x4E00 + k)).collect(); + let to_unicode = format!("{n} beginbfchar {bfchars} endbfchar"); + let mut doc = Document::new_empty(); + let f = composite(&mut doc, &encoding, Vec::new(), &to_unicode); + let reads = span_reads(|| { + Metrics::from_dict(&doc, &f); + }); + assert!(reads <= n * 64, "{reads} span reads"); +} + +/// Every glyph of a sequential range builds its text from the destination, so a long one is cut +/// to the CMap limit once, when it is read. +#[test] +fn unicode_destinations_are_cut_to_the_cmap_limit() { + let long = "0041".repeat(4096); + let cmap = format!("2 beginbfrange <0010> <0020> <{long}> <0030> <0030> [<{long}>] endbfrange 1 beginbfchar <0040> <{long}> endbfchar"); + let mut u = Unicode::default(); + parse_to_unicode(cmap.as_bytes(), &mut u); + for code in [0x10, 0x20, 0x30, 0x40] { + let text = u.text(code).unwrap_or_default(); + assert!(!text.is_empty() && text.len() <= MAX_DESTINATION_UNITS * 3, "code {code:#x}: {} bytes", text.len()); + } +} + +/// A font whose ranges cover a few low codes keeps tables that size, not a fixed 64k-entry one. +#[test] +fn low_code_tables_end_with_the_last_covered_code() { + // One range over codes 0x20..=0x21, then nothing. + let cover = [(0x20u64, Some(0usize)), (0x22, None)]; + let owner = super::owners_below_64k(&cover); + assert_eq!(owner.len(), 0x22); + assert_eq!((owner[0x1F], owner[0x20], owner[0x21]), (u32::MAX, 0, 0)); + assert!(super::owners_below_64k(&[(0x2_0000, Some(0))]).is_empty(), "no code below 65 536"); + let bits = super::bit_set_below_64k([3u32, 130, 0x2_0000].into_iter()); + assert_eq!(bits.len(), 3); + assert_eq!((bits[0], bits[2]), (1 << 3, 1 << 2)); + assert!(super::bit_set_below_64k([0x1_0000u32].into_iter()).is_empty()); +} + +fn font(doc: &mut Document, entries: Vec<(&str, Object)>) -> Dict { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("Font")); + for (k, v) in entries { + d.set(k.as_bytes().to_vec(), v); + } + let _ = doc; + d +} + +#[test] +fn embedded_editor_fonts_are_validated_without_installing_or_exporting() { + use pdfcraft_cos::Stream; + let mut doc = Document::new_empty(); + let bytes = include_bytes!("../../../../assets/fonts/Inter-Regular.ttf"); + let program = doc.add(Object::Stream(Stream::flate(Dict::new(), bytes))); + let mut descriptor = Dict::new(); + descriptor.set(b"FontFile2".to_vec(), Object::Ref(program)); + let simple = font(&mut doc, vec![("FontDescriptor", Object::Dict(descriptor.clone()))]); + assert_eq!(embedded_sfnt(&doc, &simple).unwrap(), bytes); + let composite = font(&mut doc, vec![("DescendantFonts", Object::Array(vec![Object::Dict(simple.clone())]))]); + assert_eq!(embedded_sfnt(&doc, &composite).unwrap(), bytes); + let broken = doc.add(Object::Stream(Stream::flate(Dict::new(), b"broken font"))); + descriptor.set(b"FontFile2".to_vec(), Object::Ref(broken)); + let broken = font(&mut doc, vec![("FontDescriptor", Object::Dict(descriptor))]); + assert!(embedded_sfnt(&doc, &broken).is_none()); + assert!(embedded_sfnt(&doc, &Dict::new()).is_none()); +} + +/// #2130: with no `/Widths`, an embedded program's advances win over a guess from the name +/// ("Mono" would mean Courier, 600 units for every glyph). +#[test] +fn a_font_without_widths_measures_with_its_embedded_program() { + use pdfcraft_cos::Stream; + use skrifa::MetadataProvider as _; + let mut doc = Document::new_empty(); + let bytes = include_bytes!("../../../../assets/fonts/Inter-Regular.ttf"); + let face = skrifa::FontRef::new(bytes).unwrap(); + let (size, location) = (skrifa::instance::Size::unscaled(), skrifa::instance::LocationRef::default()); + let upem = f64::from(face.metrics(size, location).units_per_em); + let advance = |c: char| f64::from(face.glyph_metrics(size, location).advance_width(face.charmap().map(c).unwrap()).unwrap()) / upem; + let program = doc.add(Object::Stream(Stream::flate(Dict::new(), bytes))); + let mut descriptor = Dict::new(); + descriptor.set(b"FontFile2".to_vec(), Object::Ref(program)); + let f = font( + &mut doc, + vec![ + ("Subtype", Object::name("TrueType")), + ("BaseFont", Object::name("HouseMono")), + ("Encoding", Object::name("WinAnsiEncoding")), + ("FontDescriptor", Object::Dict(descriptor)), + ], + ); + let m = Metrics::from_dict(&doc, &f); + for c in ['i', 'l', 'W', ' '] { + assert!((m.width(u32::from(c)) - advance(c)).abs() < 1e-9, "{c}: {} vs {}", m.width(u32::from(c)), advance(c)); + } + assert!(m.width(u32::from('i')) < 0.4, "not Courier's 0.6"); +} + +#[test] +fn mac_subset_cmap_uses_pdf_unicode_without_changing_source_program() { + use skrifa::{MetadataProvider as _, raw::TableProvider as _}; + let original = include_bytes!("../../../../assets/fonts/Inter-Regular.ttf"); + let face = skrifa::FontRef::new(original).unwrap(); + let expected = face.charmap().map('Ω').unwrap(); + let mut subset = original.to_vec(); + let count = u16::from_be_bytes(subset[4..6].try_into().unwrap()) as usize; + let entry = (0..count).map(|i| 12 + i * 16).find(|i| &subset[*i..*i + 4] == b"cmap").unwrap(); + let mut cmap = vec![0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 12]; + cmap.extend_from_slice(&[0, 6, 0, 12, 0, 0, 0, 65, 0, 1]); + cmap.extend_from_slice(&u16::try_from(expected.to_u32()).unwrap().to_be_bytes()); + let offset = subset.len() as u32; + subset[entry + 8..entry + 12].copy_from_slice(&offset.to_be_bytes()); + subset[entry + 12..entry + 16].copy_from_slice(&(cmap.len() as u32).to_be_bytes()); + subset.extend_from_slice(&cmap); + let face = skrifa::FontRef::new(&subset).unwrap(); + assert!(!face.charmap().has_map(), "egui cannot directly use Mac format 6"); + assert_eq!(face.cmap().unwrap().map_codepoint(65_u32), Some(expected)); + let mut doc = Document::new_empty(); + let program = doc.add(Object::Stream(Stream::flate(Dict::new(), &subset))); + let mut descriptor = Dict::new(); + descriptor.set(b"FontFile2".to_vec(), Object::Ref(program)); + let unicode = doc.add(Object::Stream(Stream::flate(Dict::new(), b"1 beginbfchar <41> <03A9> endbfchar"))); + let source = font(&mut doc, vec![("FontDescriptor", Object::Dict(descriptor)), ("ToUnicode", Object::Ref(unicode))]); + let editor = embedded_sfnt(&doc, &source).unwrap(); + let face = skrifa::FontRef::new(&editor).unwrap(); + assert_eq!(face.charmap().map('Ω'), Some(expected), "Unicode selects the original glyph"); + let Object::Stream(source) = doc.resolve(&Object::Ref(program)).as_ref().clone() else { + panic!("source stream"); + }; + assert_eq!(source.decoded().unwrap(), subset, "only an in-memory editor copy is adapted"); + let mut broken = vec![0; 16]; + assert!(append_editor_cmap(&mut broken, &std::collections::BTreeMap::from([(65, 1)])).is_none()); +} + +fn metrics_with_flags(flags: Option) -> Metrics { + let mut doc = Document::new_empty(); + let mut descriptor = Dict::new(); + if let Some(flags) = flags { + descriptor.set(b"Flags".to_vec(), flags); + } + let font = font(&mut doc, vec![("Subtype", Object::name("TrueType")), ("FontDescriptor", Object::Dict(descriptor))]); + Metrics::from_dict(&doc, &font) +} + +#[test] +fn font_descriptor_flags_stay_within_the_u32_domain() { + let valid = metrics_with_flags(Some(Object::Int(64 | 262_144))); + assert!(valid.italic && valid.bold); + + for flags in [Some(Object::Int(-1)), Some(Object::Int(4_294_967_296)), Some(Object::Bool(true)), None] { + let metrics = metrics_with_flags(flags); + assert!(!metrics.italic && !metrics.bold); + } +} + +#[test] +fn type3_descriptor_names_the_font_without_changing_its_metrics() { + let mut doc = Document::new_empty(); + let mut f = font( + &mut doc, + vec![ + ("Subtype", Object::name("Type3")), + ( + "FontMatrix", + Object::Array(vec![Object::Real(0.001), Object::Int(0), Object::Int(0), Object::Real(0.001), Object::Int(0), Object::Int(0)]), + ), + ("FontBBox", Object::Array(vec![Object::Int(0), Object::Int(-300), Object::Int(1000), Object::Int(1000)])), + ], + ); + let before = Metrics::from_dict(&doc, &f); + let mut descriptor = Dict::new(); + descriptor.set(b"Type".to_vec(), Object::name("FontDescriptor")); + descriptor.set(b"FontName".to_vec(), Object::name("ExampleMincho-Regular")); + descriptor.set(b"Flags".to_vec(), Object::Int(6)); + descriptor.set(b"ItalicAngle".to_vec(), Object::Int(0)); + let reference = doc.add(Object::Dict(descriptor)); + f.set(b"FontDescriptor".to_vec(), Object::Ref(reference)); + let after = Metrics::from_dict(&doc, &f); + assert_eq!(after.base_font, "ExampleMincho-Regular"); + assert_eq!((after.ascent, after.descent, after.scale), (before.ascent, before.descent, before.scale)); + assert!(!after.bold && !after.italic && !after.subset); + f.set(b"BaseFont".to_vec(), Object::name("ExplicitName")); + assert_eq!(Metrics::from_dict(&doc, &f).base_font, "ExplicitName", "an explicit BaseFont takes precedence"); +} + +#[test] +fn simple_fonts_decode_and_encode_through_their_encoding() { + let mut doc = Document::new_empty(); + let f = font( + &mut doc, + vec![("Subtype", Object::name("Type1")), ("BaseFont", Object::name("Helvetica")), ("Encoding", Object::name("WinAnsiEncoding"))], + ); + let m = Metrics::from_dict(&doc, &f); + assert_eq!(m.decode(b"Caf\xe9 \x80 \x93x\x94"), "Café € “x”"); + assert_eq!(m.encode("Café €").as_deref(), Some(&b"Caf\xe9 \x80"[..])); + assert_eq!(m.encode("Ω"), None, "not in WinAnsi"); + // Differences rename codes by glyph name. + let mut enc = Dict::new(); + enc.set(b"Differences".to_vec(), Object::Array(vec![Object::Int(65), Object::name("eacute"), Object::name("uni03A9")])); + let f = font(&mut doc, vec![("Subtype", Object::name("Type1")), ("BaseFont", Object::name("Custom")), ("Encoding", Object::Dict(enc))]); + let m = Metrics::from_dict(&doc, &f); + assert_eq!(m.decode(b"AB"), "éΩ"); + assert_eq!(m.encode("Ω").as_deref(), Some(&b"B"[..])); +} + +/// A standard-14 font with no `/Widths` is measured by the glyph each code draws, not by the +/// glyph WinAnsi gives that code (#2894); `/Widths`, when present, still go by code. +#[test] +fn standard_fonts_without_widths_measure_the_glyph_their_differences_name() { + let mut doc = Document::new_empty(); + let mut enc = Dict::new(); + enc.set(b"BaseEncoding".to_vec(), Object::name("WinAnsiEncoding")); + let diffs = vec![Object::Int(65), Object::name("W"), Object::Int(87), Object::name("A"), Object::name("uni03A9"), Object::name("not.a.glyph")]; + enc.set(b"Differences".to_vec(), Object::Array(diffs)); + let f = + font(&mut doc, vec![("Subtype", Object::name("Type1")), ("BaseFont", Object::name("Helvetica")), ("Encoding", Object::Dict(enc.clone()))]); + let m = Metrics::from_dict(&doc, &f); + assert_eq!((m.width(65), m.width(87)), (0.944, 0.667), "A and W swap their widths"); + // Omega has no WinAnsi code and the last name reads as nothing: both keep their code's width. + assert_eq!((m.width(88), m.width(89)), (0.667, 0.667)); + let f = font( + &mut doc, + vec![ + ("Subtype", Object::name("Type1")), + ("BaseFont", Object::name("Helvetica")), + ("FirstChar", Object::Int(87)), + ("Widths", Object::Array(vec![Object::Int(500)])), + ("Encoding", Object::Dict(enc)), + ], + ); + assert_eq!(Metrics::from_dict(&doc, &f).width(87), 0.5, "/Widths go by code"); +} + +#[test] +fn subset_fonts_only_encode_the_glyphs_they_have() { + let mut doc = Document::new_empty(); + // Widths for a, b only (97, 98); c (99) is 0. + let widths = Object::Array(vec![Object::Int(500), Object::Int(520), Object::Int(0)]); + let f = font( + &mut doc, + vec![ + ("Subtype", Object::name("TrueType")), + ("BaseFont", Object::name("ABCDEF+Arial")), + ("FirstChar", Object::Int(97)), + ("Widths", widths), + ("Encoding", Object::name("WinAnsiEncoding")), + ], + ); + let m = Metrics::from_dict(&doc, &f); + assert!(m.subset); + assert_eq!(m.encode("ab").as_deref(), Some(&b"ab"[..])); + assert_eq!(m.encode("abc"), None); +} + +#[test] +fn cmap_streams_decode_within_a_size_cap() { + // A valid CMap padded past the cap (a small Flate stream of whitespace) is not decoded whole. + let to_unicode = b"1 begincodespacerange <0000> endcodespacerange 1 beginbfrange <0024> <0026> <0041> endbfrange"; + let encoding = b"1 begincodespacerange <0000> endcodespacerange 1 begincidrange <0000> 0 endcidrange"; + for padded in [false, true] { + let mut doc = Document::new_empty(); + let mut stream = |cmap: &[u8]| { + let mut data = cmap.to_vec(); + if padded { + data.resize(MAX_CMAP_BYTES + 1, b' '); + } + Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), &data)))) + }; + let (tu, enc) = (stream(to_unicode), stream(encoding)); + let mut desc = Dict::new(); + desc.set(b"Subtype".to_vec(), Object::name("CIDFontType2")); + let f = font( + &mut doc, + vec![ + ("Subtype", Object::name("Type0")), + ("Encoding", enc), + ("DescendantFonts", Object::Array(vec![Object::Dict(desc)])), + ("ToUnicode", tu), + ], + ); + let m = Metrics::from_dict(&doc, &f); + assert_eq!(m.decode(&[0, 0x24]) == "A", !padded, "ToUnicode, padded: {padded}"); + assert_eq!(matches!(m.codes, Codes::Ranges(_)), !padded, "Encoding, padded: {padded}"); + } +} + +#[test] +fn composite_fonts_use_to_unicode() { + let mut doc = Document::new_empty(); + let cmap = b"/CIDInit /ProcSet findresource begin 1 begincodespacerange <0000> endcodespacerange \ + 2 beginbfchar <0003> <0020> <0010> <00E9> endbfchar 1 beginbfrange <0024> <0026> <0041> endbfrange \ + 1 beginbfrange <0030> <0031> [<0048> <0069>] endbfrange end"; + let tu = doc.add(Object::Stream(Stream::from_raw(Dict::new(), cmap.to_vec()))); + let mut desc = Dict::new(); + desc.set(b"Subtype".to_vec(), Object::name("CIDFontType2")); + desc.set( + b"W".to_vec(), + Object::Array(vec![ + Object::Int(3), + Object::Array(vec![Object::Int(250)]), + Object::Int(16), + Object::Int(16), + Object::Int(500), + Object::Int(36), + Object::Int(49), + Object::Int(600), + ]), + ); + let f = font( + &mut doc, + vec![ + ("Subtype", Object::name("Type0")), + ("BaseFont", Object::name("QWERTY+Noto")), + ("Encoding", Object::name("Identity-H")), + ("DescendantFonts", Object::Array(vec![Object::Dict(desc)])), + ("ToUnicode", Object::Ref(tu)), + ], + ); + let m = Metrics::from_dict(&doc, &f); + assert_eq!(m.decode(&[0, 0x24, 0, 0x25, 0, 3, 0, 0x30, 0, 0x31, 0, 0x10]), "AB Hié"); + assert_eq!(m.encode("Hi A").as_deref(), Some(&[0, 0x30, 0, 0x31, 0, 3, 0, 0x24][..])); + assert_eq!(m.encode("Z"), None); +} + +/// Word's Type 0 subsets map CID 0 as well as the real space glyph to U+0020 and give CID 0 a +/// width; CID 0 is `.notdef`, so a typed space takes the real one (#3223). +#[test] +fn composite_fonts_never_encode_with_cid_0() { + let mut doc = Document::new_empty(); + let cmap = b"/CIDInit /ProcSet findresource begin 1 begincodespacerange <0000> endcodespacerange \ + 1 beginbfrange <0000> <0002> [<0020> <004D> <00E1>] endbfrange \ + 1 beginbfrange <0005> <0007> [<0065> <0072> <0020>] endbfrange end"; + let tu = doc.add(Object::Stream(Stream::from_raw(Dict::new(), cmap.to_vec()))); + let mut desc = Dict::new(); + desc.set(b"Subtype".to_vec(), Object::name("CIDFontType2")); + let widths = [889, 444, 389, 278, 444, 333, 250].map(Object::Int).to_vec(); + desc.set(b"W".to_vec(), Object::Array(vec![Object::Int(0), Object::Array(vec![Object::Int(778)]), Object::Int(1), Object::Array(widths)])); + let f = font( + &mut doc, + vec![ + ("Subtype", Object::name("Type0")), + ("BaseFont", Object::name("BCDEEE+TimesNewRomanPSMT")), + ("Encoding", Object::name("Identity-H")), + ("DescendantFonts", Object::Array(vec![Object::Dict(desc)])), + ("ToUnicode", Object::Ref(tu)), + ], + ); + let m = Metrics::from_dict(&doc, &f); + assert_eq!(m.encode("Me er").as_deref(), Some(&[0, 1, 0, 5, 0, 7, 0, 5, 0, 6][..])); + assert_eq!(m.decode(&[0, 1, 0, 7, 0, 2]), "M á"); +} + +/// WSCAD writes `/ToUnicode /Identity-H` (a name, not a CMap stream) on composite fonts whose +/// codes are UTF-16 code units: the codes read as the same Unicode, as in Acrobat. Controls +/// and lone surrogates map to nothing, and a simple font's name is not a ToUnicode map. +#[test] +fn identity_to_unicode_names_read_codes_as_unicode() { + let doc = Document::new_empty(); + for name in ["Identity-H", "Identity-V"] { + let f = font( + &mut Document::new_empty(), + vec![ + ("Subtype", Object::name("Type0")), + ("BaseFont", Object::name("RobotoMono-Regular")), + ("Encoding", Object::name("Identity-H")), + ("DescendantFonts", Object::Array(vec![Object::Dict(Dict::new())])), + ("ToUnicode", Object::name(name)), + ], + ); + let m = Metrics::from_dict(&doc, &f); + assert_eq!(m.decode(&[0, b'Z', 0, 0xFC, 0, b'r', 0x20, 0xAC, 0x4E, 0x2D]), "Zür€中", "{name}"); + assert_eq!(m.text_of(0x0001), None); + assert_eq!(m.text_of(0x007F), None, "DEL is a control"); + assert_eq!(m.text_of(0x0085), None, "a C1 control"); + assert_eq!(m.text_of(0x00A0).as_deref(), Some("\u{A0}"), "the range resumes after C1"); + assert_eq!(m.text_of(0x007E).as_deref(), Some("~")); + assert_eq!(m.text_of(0xD800), None); + assert_eq!(m.text_of(0xFFFD).as_deref(), Some("\u{FFFD}")); + } + let simple = font( + &mut Document::new_empty(), + vec![("Subtype", Object::name("TrueType")), ("BaseFont", Object::name("Arial")), ("ToUnicode", Object::name("Identity-H"))], + ); + let m = Metrics::from_dict(&doc, &simple); + assert_eq!(m.text_of(0x41).as_deref(), Some("A"), "the encoding still applies"); +} + +/// Chrome's subsets leave the glyphs of `/DW` width out of `/W` (#1443): "s" (CID 0x56) is +/// still in the font, but nothing is when the font gives no default width to point at. +#[test] +fn composite_subset_glyphs_of_the_default_width_are_present() { + let metrics = |dw: bool| { + let mut doc = Document::new_empty(); + let cmap = b"/CIDInit /ProcSet findresource begin 1 begincodespacerange <0000> endcodespacerange \ + 1 beginbfrange <0055> <0057> <0072> endbfrange end"; + let tu = doc.add(Object::Stream(Stream::from_raw(Dict::new(), cmap.to_vec()))); + let mut desc = Dict::new(); + desc.set(b"Subtype".to_vec(), Object::name("CIDFontType2")); + if dw { + desc.set(b"DW".to_vec(), Object::Int(500)); + } + desc.set( + b"W".to_vec(), + Object::Array(vec![Object::Int(0x55), Object::Array(vec![Object::Int(333)]), Object::Int(0x57), Object::Array(vec![Object::Int(277)])]), + ); + let f = font( + &mut doc, + vec![ + ("Subtype", Object::name("Type0")), + ("BaseFont", Object::name("AAAAAA+LiberationSans")), + ("Encoding", Object::name("Identity-H")), + ("DescendantFonts", Object::Array(vec![Object::Dict(desc)])), + ("ToUnicode", Object::Ref(tu)), + ], + ); + Metrics::from_dict(&doc, &f) + }; + let chrome = metrics(true); + assert_eq!(chrome.encode("rst").as_deref(), Some(&[0, 0x55, 0, 0x56, 0, 0x57][..])); + assert_eq!(chrome.width(0x56), 0.5); + assert_eq!(chrome.encode("x"), None, "a character the ToUnicode map doesn't give"); + assert_eq!(metrics(false).encode("s"), None); +} + +/// A `/W` run that starts at the last CID stops there instead of overflowing onto CID 0. +#[test] +fn composite_width_run_past_the_last_cid_is_dropped() { + let widths = |first: i64| { + let mut doc = Document::new_empty(); + let mut desc = Dict::new(); + desc.set(b"Subtype".to_vec(), Object::name("CIDFontType2")); + desc.set(b"DW".to_vec(), Object::Int(1000)); + desc.set(b"W".to_vec(), Object::Array(vec![Object::Int(first), Object::Array(vec![Object::Int(100), Object::Int(200)])])); + let f = font( + &mut doc, + vec![ + ("Subtype", Object::name("Type0")), + ("Encoding", Object::name("Identity-H")), + ("DescendantFonts", Object::Array(vec![Object::Dict(desc)])), + ], + ); + Metrics::from_dict(&doc, &f) + }; + let ordinary = widths(0); + assert_eq!((ordinary.width(0), ordinary.width(1)), (0.1, 0.2)); + let last = widths(i64::from(u32::MAX)); + assert_eq!((last.width(0), last.width(u32::MAX)), (1.0, 0.1)); +} + +#[test] +fn glyph_names() { + assert_eq!(glyph_unicode("quotedblleft"), Some('“')); + assert_eq!(glyph_unicode("uni20AC"), Some('€')); + assert_eq!(glyph_unicode("u1F600"), Some('😀')); + assert_eq!(glyph_unicode("nonsense"), None); +} + +fn to_unicode(cmap: &[u8]) -> HashMap { + let mut u = Unicode::default(); + parse_to_unicode(cmap, &mut u); + let codes: Vec = u.explicit.keys().copied().chain(u.ranges.iter().flat_map(|r| r.lo..=r.hi)).collect(); + codes.into_iter().filter_map(|c| u.text(c).map(|t| (c, t.into_owned()))).collect() +} + +#[test] +fn cmap_tokens_need_no_space_between_them() { + // Hex strings, brackets and operators may touch; comments and literal strings are skipped. + let cmap = b"/CIDSystemInfo << /Registry (Adobe (x) \\) beginbfchar) >> def % beginbfchar <0001> <0058>\n\ + 2 beginbfchar<0003><0020><0010><00 E9>endbfchar \ + 1 beginbfrange<0024><0026><0041>endbfrange 1 beginbfrange<0030><0031>[<0048><0069>]endbfrange"; + let map = to_unicode(cmap); + let mut got: Vec<_> = map.iter().map(|(c, u)| (*c, u.as_str())).collect(); + got.sort_unstable(); + assert_eq!(got, [(3, " "), (0x10, "é"), (0x24, "A"), (0x25, "B"), (0x26, "C"), (0x30, "H"), (0x31, "i")]); + let (spaces, cids) = parse_cmap(b"1 begincodespacerange<00>endcodespacerange 1 begincidrange<20><7E>1 endcidrange"); + assert_eq!(spaces, [(1, vec![0], vec![0xff])]); + assert_eq!(cids, [(0x20, 0x7e, 1)]); +} + +#[test] +fn malformed_cmaps_never_panic() { + // Non-ASCII bytes inside hex strings once split a UTF-8 character and panicked. + for cmap in [ + "1 beginbfchar <0é0> <0041> <0042> endbfchar".as_bytes(), + "1 beginbfrange <0é0> <00é> <0041> endbfrange".as_bytes(), + b"1 beginbfchar <0001> <004", + b"1 beginbfchar <123> <0041> <0002> <0003> endbfchar", + b"1 beginbfrange <0001> [<0041>] endbfrange", + b"1 beginbfrange <0001> <0002> [<0041> (", + b"1 beginbfrange <0001>", + b"\xff\xfe beginbfchar \x80<\xc3\xa9> <0041> endbfchar %", + ] { + to_unicode(cmap); + parse_cmap(cmap); + } + // Code counters at the top of the range stop instead of overflowing. + let map = to_unicode(b"1 beginbfrange [<0041> <0042> <0043>] endbfrange"); + assert_eq!(map.len(), 2); + assert_eq!(map.get(&u32::MAX).map(String::as_str), Some("B")); + // A malformed array entry keeps its place, so the next one maps the next code. + let map = to_unicode(b"1 beginbfrange <0001> <0003> [<0041> <0043>] endbfrange"); + assert_eq!((map.get(&2), map.get(&3).map(String::as_str)), (None, Some("C"))); + let mut m = Metrics::fallback(); + m.cid_map = Spans::new(parse_cmap(b"1 begincidrange <0000> 4294967295 endcidrange").1); + assert_eq!(m.cid(5), u32::MAX); + let mut doc = Document::new_empty(); + let mut enc = Dict::new(); + enc.set(b"Differences".to_vec(), Object::Array(vec![Object::Int(i64::MAX), Object::name("A"), Object::name("B")])); + let f = font(&mut doc, vec![("Subtype", Object::name("Type1")), ("Encoding", Object::Dict(enc))]); + assert_eq!(unicode_map(&doc, &f, false).text(u32::MAX).as_deref(), Some("B")); +} + +#[test] +fn sequential_ranges_past_the_entry_cap_still_map() { + // 17 ranges of 65 536 codes: 1 114 112 codes from 0.4 KB of CMap, more than MAX_CMAP_ENTRIES + // (the first 16 ranges use up the whole cap). Each range maps its codes upward from 'A', so + // the last code of the last range, 0x100019, is 'A' + 0x19 = 'Z'. + let mut cmap = b"17 beginbfrange ".to_vec(); + for k in 0..17u32 { + cmap.extend(format!("<{:08X}> <{:08X}> <0041> ", k << 16, (k << 16) | 0xffff).bytes()); + } + cmap.extend(b"endbfrange"); + let mut doc = Document::new_empty(); + let tu = doc.add(Object::Stream(Stream::from_raw(Dict::new(), cmap))); + let f = font( + &mut doc, + vec![ + ("Subtype", Object::name("Type0")), + ("BaseFont", Object::name("Wide")), + ("Encoding", Object::name("Identity-H")), + ("ToUnicode", Object::Ref(tu)), + ], + ); + let m = Metrics::from_dict(&doc, &f); + assert_eq!(m.text_of(0x0010_0019).as_deref(), Some("Z")); +} + +#[test] +fn a_cmap_adds_at_most_max_entries() { + // 200 ranges of 65 536 codes would be 13 million codes from 4 KB of stream: 200 records. + let mut cmap = b"200 beginbfrange ".to_vec(); + for k in 0..200u32 { + cmap.extend(format!("<{:08X}> <{:08X}> <0041> ", k << 16, (k << 16) | 0xffff).bytes()); + } + cmap.extend(b"endbfrange"); + let mut u = Unicode::default(); + parse_to_unicode(&cmap, &mut u); + assert_eq!((u.ranges.len(), u.explicit.len()), (200, 0)); + // Explicit entries are what the budget counts: 250 of the 300 here are kept. + let mut many = b"300 beginbfchar ".to_vec(); + for k in 0..300u32 { + many.extend(format!("<{k:04X}> <{:04X}> ", 0x4E00 + k).bytes()); + } + many.extend(b"endbfchar"); + let mut u = Unicode::default(); + parse_to_unicode_within(&many, &mut u, 250); + assert_eq!((u.explicit.len(), u.text(249).is_some(), u.text(250).is_some()), (250, true, false)); + let mut many = b"begincidrange ".to_vec(); + for k in 0..MAX_CMAP_RANGES + 10 { + many.extend(format!("<{k:08X}> <{k:08X}> 1 ").bytes()); + } + assert_eq!(parse_cmap(&many).1.len(), MAX_CMAP_RANGES); +} + +#[test] +fn a_range_is_one_record_however_many_codes() { + let mut u = Unicode::default(); + parse_to_unicode(b"1 beginbfrange <0000> <0041> endbfrange", &mut u); + assert_eq!((u.ranges.len(), u.explicit.len()), (1, 0)); + assert_eq!(u.text(0xFFFF).as_deref(), Some("@"), "0x41 + 0xFFFF wraps to U+0040"); +} + +#[test] +fn sequential_ranges_count_against_the_budget() { + // Range k maps codes k and k + 1 and costs two, so a budget of 250 keeps ranges 0 to 124. + let mut cmap = Vec::new(); + for k in 0..300u32 { + cmap.extend(format!("1 beginbfrange <{k:08X}> <{:08X}> <0041> endbfrange ", k + 1).bytes()); + } + let mut u = Unicode::default(); + parse_to_unicode_within(&cmap, &mut u, 250); + assert_eq!((u.ranges.len(), u.text(125).is_some(), u.text(126).is_none()), (125, true, true)); +} + +#[test] +fn explicit_and_range_mappings_override_the_encoding() { + let mut doc = Document::new_empty(); + let cmap = b"1 beginbfchar <41> <0058> endbfchar 1 beginbfrange <42> <43> <0059> endbfrange"; + let tu = doc.add(Object::Stream(Stream::from_raw(Dict::new(), cmap.to_vec()))); + let f = font( + &mut doc, + vec![ + ("Subtype", Object::name("Type1")), + ("BaseFont", Object::name("Custom")), + ("Encoding", Object::name("WinAnsiEncoding")), + ("ToUnicode", Object::Ref(tu)), + ], + ); + let m = Metrics::from_dict(&doc, &f); + let shown: Vec> = (0x41..=0x44).map(|c| m.text_of(c).map(|t| t.into_owned())).collect(); + assert_eq!(shown, [Some("X".to_string()), Some("Y".to_string()), Some("Z".to_string()), Some("D".to_string())]); +} + +/// Deterministic input for the differential test below. +struct Gen(u64); + +impl Gen { + fn below(&mut self, n: u32) -> u32 { + self.0 = self.0.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407); + ((self.0 >> 33) % u64::from(n)) as u32 + } + + /// A destination: a unit from a pool of edge cases, a surrogate pair, or a lone high surrogate. + fn units(&mut self) -> Vec { + const POOL: [u16; 12] = [0x41, 0x42, 0xE9, 0x0, 0xFFFD, 0xFFFE, 0xFFFF, 0x100, 0x3042, 0xD800, 0xDC00, 0xDFFF]; + match self.below(6) { + 0 => vec![0xD835, 0xDC00 + self.below(0x400) as u16], + 1 => vec![0xD800, 0x41], + _ => vec![POOL[self.below(POOL.len() as u32) as usize]], + } + } +} + +fn hex(units: &[u16]) -> String { + units.iter().map(|u| format!("{u:04X}")).collect() +} + +#[test] +fn lookups_match_the_expanded_mappings() { + // Random ToUnicode CMaps; `expected` is what expanding their entries in order gives. + let mut g = Gen(0x2545_f491_4f6c_dd1d); + for _ in 0..300 { + let mut cmap = String::from("1 begincodespacerange <0000> endcodespacerange "); + let mut expected: HashMap = HashMap::new(); + for _ in 0..1 + g.below(12) { + match g.below(3) { + 0 => { + let (code, u) = (g.below(300), g.units()); + cmap += &format!("1 beginbfchar <{code:04X}> <{}> endbfchar ", hex(&u)); + expected.insert(code, String::from_utf16_lossy(&u)); + } + 1 => { + let (lo, width, u) = (g.below(300), g.below(13), g.units()); + cmap += &format!("1 beginbfrange <{lo:04X}> <{:04X}> <{}> endbfrange ", lo + width, hex(&u)); + for k in 0..=width { + let mut v = u.clone(); + if let Some(last) = v.last_mut() { + *last = last.wrapping_add(k as u16); + } + expected.insert(lo + k, String::from_utf16_lossy(&v)); + } + } + _ => { + let (lo, width) = (g.below(300), g.below(5)); + let dests: Vec> = (0..=width).map(|_| g.units()).collect(); + let list: Vec = dests.iter().map(|d| format!("<{}>", hex(d))).collect(); + cmap += &format!("1 beginbfrange <{lo:04X}> <{:04X}> [{}] endbfrange ", lo + width, list.join(" ")); + for (k, d) in dests.iter().enumerate() { + expected.insert(lo + k as u32, String::from_utf16_lossy(d)); + } + } + } + } + let mut doc = Document::new_empty(); + let tu = doc.add(Object::Stream(Stream::from_raw(Dict::new(), cmap.clone().into_bytes()))); + let f = font( + &mut doc, + vec![ + ("Subtype", Object::name("Type0")), + ("BaseFont", Object::name("Gen")), + ("Encoding", Object::name("Identity-H")), + ("ToUnicode", Object::Ref(tu)), + ], + ); + let m = Metrics::from_dict(&doc, &f); + for code in 0..=320u32 { + let got = m.text_of(code).map(|t| t.into_owned()); + assert_eq!(got, expected.get(&code).cloned(), "code {code:#06x} in {cmap}"); + } + let mut reverse: HashMap = HashMap::new(); + // Code 0 is CID 0, `.notdef`, which no character is encoded with. + for (&code, text) in expected.iter().filter(|&(&code, _)| code != 0) { + if let Some(c) = only_char(text) { + keep(&mut reverse, c, code); + } + } + let mut chars: Vec = expected.values().flat_map(|t| t.chars()).chain(['A', '\u{FFFD}', '\u{0}', '\u{1D400}']).collect(); + chars.sort_unstable(); + chars.dedup(); + for c in chars { + let want = reverse.get(&c).map(|code| code.to_be_bytes()[2..].to_vec()); + assert_eq!(m.encode(&c.to_string()), want, "char {c:?} in {cmap}"); + } + } +} + +#[test] +fn fffd_lookups_skip_overridden_surrogates() { + // Each record maps every code to itself, so its surrogate offsets decode to U+FFFD. The last + // record overrides the surrogates with letters, so each lookup walks past them to U+FFFD itself. + let mut cmap = "1 beginbfrange <0000> <0000> endbfrange ".repeat(300); + cmap.push_str("1 beginbfrange <0041> endbfrange"); + let mut doc = Document::new_empty(); + let tu = doc.add(Object::Stream(Stream::from_raw(Dict::new(), cmap.into_bytes()))); + let f = font( + &mut doc, + vec![ + ("Subtype", Object::name("Type0")), + ("BaseFont", Object::name("Gen")), + ("Encoding", Object::name("Identity-H")), + ("ToUnicode", Object::Ref(tu)), + ], + ); + let m = Metrics::from_dict(&doc, &f); + assert_eq!(m.encode("\u{FFFD}"), Some(vec![0xFF, 0xFD])); +} + +#[test] +fn hostile_ranges_stay_bounded() { + // A range wider than 65 536 codes is ignored, a reversed one maps nothing, and a maximal + // one-code range maps just its own code. + let mut u = Unicode::default(); + let cmap = b"1 beginbfrange <00000000> <0041> endbfrange \ + 1 beginbfrange <0005> <0001> <0041> endbfrange \ + 1 beginbfrange endbfrange"; + parse_to_unicode(cmap, &mut u); + assert_eq!((u.ranges.len(), u.text(u32::MAX).as_deref()), (0, Some("\u{FFFF}"))); +} + +#[test] +fn low_codes_resolve_to_the_covering_range() { + let mut u = Unicode::default(); + let cmap = b"1 beginbfrange <0005> <0007> <0041> endbfrange 1 beginbfrange <0064> <0065> <0042> endbfrange \ + 1 beginbfrange <0000FFFE> <00010001> <0043> endbfrange"; + parse_to_unicode(cmap, &mut u); + let covered: Vec = (0..0x70).filter(|&c| u.covering(c).is_some()).collect(); + assert_eq!(covered, [5, 6, 7, 100, 101]); + assert_eq!((u.covering(0xFFFF).is_some(), u.covering(0x1_0001).is_some(), u.covering(0x1_0002).is_none()), (true, true, true)); +} diff --git a/crates/forms/README.md b/crates/forms/README.md index 93b65bd14e..ba9dc18f48 100644 --- a/crates/forms/README.md +++ b/crates/forms/README.md @@ -87,10 +87,15 @@ let cells: Vec = grid.cells_for(names)?; // "Table1_1", "Table left alone. `calculations_changed` compares two readings of a form to say which formulas an edit rewrote. - The calculation order (`/CO`, §12.7.3): `calculation_order` lists the calculated fields in the - order `recalculate` runs them, with the fields each formula reads (`Calculate::reads`, + order `recalculate` runs them, each with its field object (two can share a name in a malformed + form), with the fields each formula reads (`Calculate::reads`, `af::calculation_reads`; names built at run time aren't known) and what is wrong with the order: a field reading one that calculates later, a loop, a field `/CO` leaves out, and `/CO` - entries that calculate nothing. `CalculationOrder::fixed` is Fix order: a stable topological + entries that calculate nothing. Two issues are about the formula itself: it reads a field the + form doesn't have (`MissingField`; the field of that name and a field under it count as there, + as recalculation and `getField` find them, but not an Acrobat widget name such as `price.0`, + which neither resolves), or its script builds names at run time (`BuiltNames`, + `af::reads_built_names`), so its reads are incomplete. `CalculationOrder::fixed` is Fix order: a stable topological sort that keeps the current order wherever it can and leaves loops as they are. `set_calculation_order` writes `/CO` afresh. A field that gets a calculation joins the end of the order; one whose calculation changes keeps its place. @@ -108,3 +113,7 @@ Rich-text values and exact appearance metrics/layout remain incomplete. JavaScri execution and FDF/XFDF exchange are integrated through the engine; this crate provides field actions and native AF rules. `/MK /R` rotation (0, 90, 180, 270) is drawn into the appearance. + +## Button export values + +`FieldProps::export_value` edits a zero-based widget's `/Opt` value while preserving appearance names, `/AP`, `/AS`, `/V` and `/DV`. Widgets sharing an on-state remain consistent. Export-only changes do not regenerate appearances. Invalid indices, empty/Off values and ambiguous/conflicting mappings are rejected. `Field::export_values` resolves selected states for data export. diff --git a/crates/forms/proptest-regressions/af/tests.txt b/crates/forms/proptest-regressions/af/tests.txt new file mode 100644 index 0000000000..70c4d0b525 --- /dev/null +++ b/crates/forms/proptest-regressions/af/tests.txt @@ -0,0 +1,7 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc 81dc3893ad0b46189d4e80199f44f8e58fcf0d193f67e266e37f3846f756a11e # shrinks to names = [" "] diff --git a/crates/forms/src/actions.rs b/crates/forms/src/actions.rs index 5eb57c6c1b..5ebe3269cc 100644 --- a/crates/forms/src/actions.rs +++ b/crates/forms/src/actions.rs @@ -262,17 +262,21 @@ fn write(doc: &mut Document, a: &FieldAction, pages: &[pdfcraft_cos::ObjRef]) -> /// Rewrite one trigger of a widget to `new` (`None` clears it), given the `old` value read from /// the widget. `aa` and `a_entry` are the widget's `/AA` dict and `/A` entry, updated in place. -#[allow(clippy::too_many_arguments)] +struct TriggerUpdate<'a> { + trigger: Trigger, + replacement: Option<&'a FieldAction>, + previous: Option, +} + fn replace_trigger( doc: &mut Document, pages: &[pdfcraft_cos::ObjRef], scripts: &mut pdfcraft_cos::DecodeBudget, - t: Trigger, - new: Option<&FieldAction>, - old: Option, + update: TriggerUpdate<'_>, aa: &mut Dict, a_entry: &mut Option, ) -> Result<(), FormError> { + let TriggerUpdate { trigger: t, replacement: new, previous: old } = update; let old_action = old.as_ref().and_then(|o| read(doc, o, pages, scripts)); if new.is_some() && new == old_action.as_ref() { return Ok(()); @@ -322,7 +326,7 @@ pub fn set_field_actions(doc: &mut Document, name: &str, actions: &[(Trigger, Fi None => wd.get(b"A").cloned().or_else(|| aa.get(b"U").cloned()), Some(k) => aa.get(k).cloned(), }; - replace_trigger(doc, &pages, &mut scripts, t, new, old, &mut aa, &mut a_entry)?; + replace_trigger(doc, &pages, &mut scripts, TriggerUpdate { trigger: t, replacement: new, previous: old }, &mut aa, &mut a_entry)?; } doc.update_dict(w.obj, |d| { match &a_entry { diff --git a/crates/forms/src/af.rs b/crates/forms/src/af.rs index 3119481cff..86c7971c31 100644 --- a/crates/forms/src/af.rs +++ b/crates/forms/src/af.rs @@ -13,6 +13,17 @@ use std::collections::VecDeque; use std::fmt::Write as _; use std::ops::Range; +mod calc; +mod format; +mod script; + +#[cfg(test)] +use calc::{Notation, eval_notation, notation_name, notation_tokens, unique}; +pub use calc::{calculate, calculation_reads, notation_js, reads_built_names, rename_in_calculation}; +pub use format::{DATE_PRESETS, DateTime, TIME_PRESETS, format_date, format_value, js_number, keystroke, make_number, parse_date, validate}; +use script::{Arg, call}; +pub use script::{NOTATION_NOT_STORABLE, calculate_js, format_js, notation_storable, parse_calculate, parse_format, parse_validate, validate_js}; + /// The Format tab. #[derive(Clone, Debug, PartialEq, Default)] pub enum Format { @@ -112,1699 +123,8 @@ impl Actions { } } -// ── parsing scripts ───────────────────────────────────────────────────────────────────────── - -#[derive(Clone, Debug, PartialEq)] -enum Arg { - Num(f64), - Str(String), - Bool(bool), - List(Vec), -} - -impl Arg { - fn num(&self) -> Option { - match self { - Arg::Num(n) => Some(*n), - Arg::Bool(b) => Some(f64::from(u8::from(*b))), - Arg::Str(s) => s.trim().parse().ok(), - Arg::List(_) => None, - } - } - - fn string(&self) -> Option { - match self { - Arg::Str(s) => Some(s.clone()), - Arg::Num(n) => Some(n.to_string()), - _ => None, - } - } - - fn truthy(&self) -> bool { - match self { - Arg::Bool(b) => *b, - Arg::Num(n) => *n != 0.0, - Arg::Str(s) => !s.is_empty(), - Arg::List(l) => !l.is_empty(), - } - } -} - -/// How deeply script arguments and field notation may nest; deeper input is rejected rather -/// than recursed into (a hostile file could otherwise overflow the stack). -const MAX_NESTING: usize = 64; - -fn hex4(bytes: &[u8]) -> Option { - if bytes.len() != 4 { - return None; - } - bytes.iter().try_fold(0u16, |value, byte| { - let digit = match byte { - b'0'..=b'9' => byte - b'0', - b'a'..=b'f' => byte - b'a' + 10, - b'A'..=b'F' => byte - b'A' + 10, - _ => return None, - }; - value.checked_mul(16)?.checked_add(u16::from(digit)) - }) -} - -struct Parser<'a> { - s: &'a [u8], - i: usize, - depth: usize, -} - -impl Parser<'_> { - fn ws(&mut self) { - while self.i < self.s.len() && (self.s[self.i].is_ascii_whitespace()) { - self.i += 1; - } - } - - fn string(&mut self) -> Option { - let q = self.s[self.i]; - self.i += 1; - let mut out = Vec::new(); - while self.i < self.s.len() { - let c = self.s[self.i]; - self.i += 1; - match c { - b'\\' if self.i < self.s.len() => { - let e = self.s[self.i]; - self.i += 1; - match e { - b'n' => out.push(b'\n'), - b'r' => out.push(b'\r'), - b't' => out.push(b'\t'), - b'b' => out.push(0x08), - b'f' => out.push(0x0C), - b'v' => out.push(0x0B), - b'0' => out.push(0), - b'x' => { - let digits = self.s.get(self.i..self.i.checked_add(2)?)?; - let code = hex4(&[b'0', b'0', digits[0], digits[1]])?; - self.i += 2; - let ch = char::from_u32(u32::from(code)).unwrap_or('\u{FFFD}'); - let mut encoded = [0u8; 4]; - out.extend_from_slice(ch.encode_utf8(&mut encoded).as_bytes()); - } - b'u' => { - let code = hex4(self.s.get(self.i..self.i.checked_add(4)?)?)?; - self.i += 4; - let code = if (0xD800..=0xDBFF).contains(&code) && self.s.get(self.i..self.i.checked_add(6)?)?.starts_with(b"\\u") { - let low = hex4(self.s.get(self.i.checked_add(2)?..self.i.checked_add(6)?)?)?; - if (0xDC00..=0xDFFF).contains(&low) { - self.i += 6; - 0x10000 + ((u32::from(code) - 0xD800) << 10) + (u32::from(low) - 0xDC00) - } else { - u32::from(code) - } - } else { - u32::from(code) - }; - let ch = char::from_u32(code).unwrap_or('\u{FFFD}'); - let mut encoded = [0u8; 4]; - out.extend_from_slice(ch.encode_utf8(&mut encoded).as_bytes()); - } - other => out.push(other), - } - } - c if c == q => return Some(String::from_utf8_lossy(&out).into_owned()), - c => out.push(c), - } - } - None - } - - fn arg(&mut self) -> Option { - self.ws(); - let c = *self.s.get(self.i)?; - match c { - b'"' | b'\'' => self.string().map(Arg::Str), - b'[' => { - self.i += 1; - self.nested(b']').map(Arg::List) - } - _ if self.s[self.i..].starts_with(b"new Array") => { - self.i += b"new Array".len(); - self.ws(); - if self.s.get(self.i) != Some(&b'(') { - return None; - } - self.i += 1; - self.nested(b')').map(Arg::List) - } - _ if self.s[self.i..].starts_with(b"true") => { - self.i += 4; - Some(Arg::Bool(true)) - } - _ if self.s[self.i..].starts_with(b"false") => { - self.i += 5; - Some(Arg::Bool(false)) - } - _ => { - let start = self.i; - while self.i < self.s.len() && (self.s[self.i].is_ascii_digit() || matches!(self.s[self.i], b'.' | b'-' | b'+' | b'e' | b'E')) { - self.i += 1; - } - std::str::from_utf8(&self.s[start..self.i]).ok()?.parse().ok().map(Arg::Num) - } - } - } - - /// A list inside another, at most [`MAX_NESTING`] deep. - fn nested(&mut self, close: u8) -> Option> { - if self.depth >= MAX_NESTING { - return None; - } - self.depth += 1; - let out = self.list(close); - self.depth -= 1; - out - } - - fn list(&mut self, close: u8) -> Option> { - let mut out = Vec::new(); - loop { - self.ws(); - if self.s.get(self.i) == Some(&close) { - self.i += 1; - return Some(out); - } - out.push(self.arg()?); - self.ws(); - match self.s.get(self.i) { - Some(b',') => self.i += 1, - Some(c) if *c == close => { - self.i += 1; - return Some(out); - } - _ => return None, - } - } - } -} - -/// The first call to `name(` in `js` and its arguments. -fn call(js: &str, name: &str) -> Option> { - let at = js.find(&format!("{name}("))?; - // Not part of a longer identifier (AFDate_Format vs AFDate_FormatEx). - if js[..at].chars().last().is_some_and(|c| c.is_alphanumeric() || c == '_') { - return None; - } - let mut p = Parser { s: js.as_bytes(), i: at + name.len() + 1, depth: 0 }; - p.list(b')') -} - -/// Acrobat's AFDate_Format(n) / AFTime_Format(n) presets. -pub const DATE_PRESETS: [&str; 14] = [ - "m/d", - "m/d/yy", - "mm/dd/yy", - "mm/yy", - "d-mmm", - "d-mmm-yy", - "dd-mmm-yy", - "yy-mm-dd", - "mmm-yy", - "mmmm-yy", - "mmm d, yyyy", - "mmmm d, yyyy", - "m/d/yy h:MM tt", - "m/d/yy HH:MM", -]; -pub const TIME_PRESETS: [&str; 4] = ["HH:MM", "h:MM tt", "HH:MM:ss", "h:MM:ss tt"]; - -pub fn parse_format(js: &str) -> Option { - let a = |args: &[Arg], i: usize| args.get(i).and_then(Arg::num).unwrap_or(0.0).clamp(0.0, 255.0) as u8; - if let Some(args) = call(js, "AFNumber_Format") { - return Some(Format::Number { - decimals: a(&args, 0), - sep: a(&args, 1), - neg: a(&args, 2), - currency: args.get(4).and_then(Arg::string).unwrap_or_default(), - prepend: args.get(5).is_none_or(Arg::truthy), - }); - } - if let Some(args) = call(js, "AFPercent_Format") { - return Some(Format::Percent { decimals: a(&args, 0), sep: a(&args, 1) }); - } - if let Some(args) = call(js, "AFDate_FormatEx") { - return Some(Format::Date(args.first().and_then(Arg::string)?)); - } - if let Some(args) = call(js, "AFDate_Format") { - return Some(Format::Date(DATE_PRESETS.get(a(&args, 0) as usize)?.to_string())); - } - if let Some(args) = call(js, "AFTime_FormatEx") { - return Some(Format::Time(args.first().and_then(Arg::string)?)); - } - if let Some(args) = call(js, "AFTime_Format") { - return Some(Format::Time(TIME_PRESETS.get(a(&args, 0) as usize)?.to_string())); - } - if let Some(args) = call(js, "AFSpecial_Format") { - return Some(Format::Special(a(&args, 0).min(3))); - } - if let Some(args) = call(js, "AFSpecial_KeystrokeEx") { - return Some(Format::Mask(args.first().and_then(Arg::string)?)); - } - None -} - -pub fn parse_validate(js: &str) -> Option { - let args = call(js, "AFRange_Validate")?; - let flag = |i: usize| args.get(i).is_some_and(Arg::truthy); - let num = |i: usize| args.get(i).and_then(Arg::num); - Some(Validate::Range { min: if flag(0) { num(1) } else { None }, max: if flag(2) { num(3) } else { None } }) -} - -pub fn parse_calculate(js: &str) -> Option { - // Simplified field notation: Acrobat keeps the expression in a comment. A script is read for - // its comments only when it spells the marker. - if js.contains("BVCALC") - && let Some(range) = notation_range(js, &js_tokens(js).1) - { - return Some(Calculate::Notation(js.get(range)?.trim().to_string())); - } - let args = call(js, "AFSimple_Calculate")?; - let op = match args.first().and_then(Arg::string)?.as_str() { - "SUM" => CalcOp::Sum, - "PRD" => CalcOp::Product, - "AVG" => CalcOp::Average, - "MIN" => CalcOp::Minimum, - "MAX" => CalcOp::Maximum, - _ => return None, - }; - let fields = match args.get(1)? { - Arg::List(l) => l.iter().filter_map(Arg::string).collect(), - Arg::Str(s) => s.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect(), - _ => return None, - }; - Some(Calculate::Simple { op, fields }) -} - -// ── writing scripts (what Acrobat's tabs generate) ───────────────────────────────────────── - -fn js_str(s: &str) -> String { - let mut o = String::from("\""); - for c in s.chars() { - match c { - '"' | '\\' => { - o.push('\\'); - o.push(c); - } - '\n' => o.push_str("\\n"), - '\r' => o.push_str("\\r"), - '\t' => o.push_str("\\t"), - c if c.is_ascii() && !c.is_control() => o.push(c), - c => { - let mut units = [0u16; 2]; - for unit in c.encode_utf16(&mut units) { - o.push_str(&format!("\\u{unit:04X}")); - } - } - } - } - o.push('"'); - o -} - -/// The (format, keystroke) scripts for a format. -pub fn format_js(f: &Format) -> Option<(String, String)> { - Some(match f { - Format::None => return None, - Format::Number { decimals, sep, neg, currency, prepend } => { - let args = format!("{decimals}, {sep}, {neg}, 0, {}, {prepend}", js_str(currency)); - (format!("AFNumber_Format({args});"), format!("AFNumber_Keystroke({args});")) - } - Format::Percent { decimals, sep } => (format!("AFPercent_Format({decimals}, {sep});"), format!("AFPercent_Keystroke({decimals}, {sep});")), - Format::Date(f) => (format!("AFDate_FormatEx({});", js_str(f)), format!("AFDate_KeystrokeEx({});", js_str(f))), - Format::Time(f) => (format!("AFTime_FormatEx({});", js_str(f)), format!("AFTime_KeystrokeEx({});", js_str(f))), - Format::Special(n) => (format!("AFSpecial_Format({n});"), format!("AFSpecial_Keystroke({n});")), - Format::Mask(m) => (String::new(), format!("AFSpecial_KeystrokeEx({});", js_str(m))), - }) -} - -pub fn validate_js(v: &Validate) -> Option { - match v { - Validate::None => None, - Validate::Range { min, max } => { - Some(format!("AFRange_Validate({}, {}, {}, {});", min.is_some(), min.unwrap_or(0.0), max.is_some(), max.unwrap_or(0.0))) - } - } -} - -pub fn calculate_js(c: &Calculate) -> Option { - match c { - Calculate::None => None, - Calculate::Simple { op, fields } => Some(format!( - "AFSimple_Calculate({}, new Array({}));", - js_str(op.code()), - fields.iter().map(|f| js_str(f)).collect::>().join(", ") - )), - // Acrobat's own translation follows the comment, so a viewer that runs the script instead - // of reading the comment computes the same value (a placeholder here showed 0). - Calculate::Notation(expr) => Some(format!("/** BVCALC {expr} EVCALC **/ event.value = {};", notation_js(expr))), - } -} - -// ── renaming fields in calculations ───────────────────────────────────────────────────────── - -/// A JavaScript token, as far as finding the field names a calculation reads needs. -#[derive(Clone, Debug, PartialEq)] -enum Js { - Ident, - /// A string literal, its escapes undone. - Str(String), - Punct(char), - /// A number, a regular expression or a template literal. - Other, -} - -/// A [`Js`] token with its byte range in the script. -type Token = (Range, Js); - -/// Words after which `/` starts a regular expression instead of dividing. -const BEFORE_REGEX: [&str; 13] = ["return", "typeof", "case", "do", "else", "in", "of", "new", "delete", "void", "throw", "instanceof", "yield"]; - -/// Words whose condition, in parentheses, comes before a statement, so a `/` after it starts a -/// regular expression. The `while` that ends a `do` loop counts too: the loop ends at its `)`, so -/// a `/` after it starts the next statement, and can't divide. -const BEFORE_CONDITION: [&str; 4] = ["if", "while", "for", "with"]; - -/// Whether a line ends at `s[j]`: JavaScript ends one at `\n`, `\r`, U+2028 and U+2029. -fn line_ends_at(s: &[u8], j: usize) -> bool { - matches!(s.get(j..), Some([b'\n' | b'\r', ..] | [0xE2, 0x80, 0xA8 | 0xA9, ..])) -} - -/// JavaScript's tokens with their byte ranges, and apart from them the text of each comment -/// (inside its `//`, or its `/*` and `*/`): enough of the language to tell code from strings, -/// comments and regular expressions. The tokens stop where a string, comment or template literal -/// never ends; a comment that never ends runs to the end of the script. -fn js_tokens(js: &str) -> (Vec, Vec>) { - let s = js.as_bytes(); - // Whether `s[j]` is part of a word. Bytes past ASCII are parts of identifiers, except - // U+2028 and U+2029, which end a line: a range never ends inside a character. - let word = |j: usize| s.get(j).is_some_and(|&b| b.is_ascii_alphanumeric() || b == b'_' || b == b'$' || !b.is_ascii()) && !line_ends_at(s, j); - let mut out: Vec = Vec::new(); - let mut comments = Vec::new(); - let mut i = 0; - // Where a regular expression that never ended stopped being read: every `/` before it - // divides, so a line of them is read once, not once for each. - let mut divides_until = 0; - // For each `(` still open, whether it opened the condition of `if`, `while`, `for` or - // `with`; and whether the last token is the `)` that closed one. - let mut conditions: Vec = Vec::new(); - let mut after_condition = false; - 'scan: while let Some(&c) = s.get(i) { - let start = i; - let token = match c { - _ if c.is_ascii_whitespace() => { - i += 1; - continue; - } - // U+2028 or U+2029 between tokens ends a line, and is skipped whole: read a byte at a - // time, the rest of it would be read from inside the character. - 0xE2 if line_ends_at(s, i) => { - i += 3; - continue; - } - b'/' if s.get(i + 1) == Some(&b'/') => { - i = (i + 2..s.len()).find(|&j| line_ends_at(s, j)).unwrap_or(s.len()); - comments.push(start + 2..i); - continue; - } - b'/' if s.get(i + 1) == Some(&b'*') => match js.get(i + 2..).and_then(|rest| rest.find("*/")) { - Some(n) => { - comments.push(i + 2..i + 2 + n); - i += n + 4; - continue; - } - None => { - comments.push(i + 2..s.len()); - break 'scan; - } - }, - b'"' | b'\'' => { - let mut p = Parser { s, i, depth: 0 }; - let Some(text) = p.string() else { break 'scan }; - i = p.i; - Js::Str(text) - } - b'`' => { - let mut j = i + 1; - loop { - match s.get(j) { - None => break 'scan, - Some(b'\\') => j += 2, - Some(b'`') => break, - Some(_) => j += 1, - } - } - i = j + 1; - Js::Other - } - b'/' if i >= divides_until && regex_may_follow(js, &out, after_condition) => { - // A regular expression ends at a `/` outside a class; a line break first means it - // was a division after all. - let (mut j, mut class) = (i + 1, false); - let end = loop { - match s.get(j) { - None => break None, - Some(_) if line_ends_at(s, j) => break None, - Some(b'\\') => j += 2, - Some(b'/') if !class => break Some(j), - Some(b) => { - class = match b { - b'[' => true, - b']' => false, - _ => class, - }; - j += 1; - } - } - }; - match end { - Some(e) => { - i = e + 1; - while word(i) { - i += 1; - } - Js::Other - } - None => { - divides_until = j; - i += 1; - Js::Punct('/') - } - } - } - _ if c.is_ascii_digit() => { - while word(i) || s.get(i) == Some(&b'.') { - i += 1; - } - Js::Other - } - _ if word(i) => { - while word(i) { - i += 1; - } - Js::Ident - } - _ => { - i += 1; - Js::Punct(char::from(c)) - } - }; - after_condition = match token { - Js::Punct('(') => { - conditions.push(opens_condition(js, &out)); - false - } - Js::Punct(')') => conditions.pop().unwrap_or(false), - _ => false, - }; - out.push((start..i, token)); - } - (out, comments) -} - -/// Whether a `/` after `tokens` starts a regular expression: after an operator, an opening -/// bracket, a keyword such as `return` or the condition of `if`, `while`, `for` or `with`, not -/// after a value. `after_condition` says the last token is the `)` that ends such a condition. -fn regex_may_follow(js: &str, tokens: &[Token], after_condition: bool) -> bool { - match tokens.last() { - None => true, - Some((range, Js::Ident)) => js.get(range.clone()).is_some_and(|w| BEFORE_REGEX.contains(&w)), - Some((_, Js::Punct(')'))) => after_condition, - Some((_, Js::Punct(c))) => !matches!(c, ']' | '}'), - Some((_, Js::Str(_) | Js::Other)) => false, - } -} - -/// Whether a `(` after `tokens` opens the condition of `if`, `while`, `for` or `with`, and not -/// the arguments of a method that has one of those names (`o.if(x)`). -fn opens_condition(js: &str, tokens: &[Token]) -> bool { - let mut back = tokens.iter().rev(); - let Some((range, Js::Ident)) = back.next() else { return false }; - js.get(range.clone()).is_some_and(|w| BEFORE_CONDITION.contains(&w)) && !matches!(back.next(), Some((_, Js::Punct('.')))) -} - -/// Where a Calculate script keeps its simplified field notation: between `BVCALC` and the -/// `EVCALC` after it, in the first of `comments` that holds both, as Acrobat writes it -/// (`/** BVCALC a + b EVCALC **/`). A string or code that spells the markers is not the notation. -fn notation_range(js: &str, comments: &[Range]) -> Option> { - comments.iter().find_map(|comment| { - let text = js.get(comment.clone())?; - let a = text.find("BVCALC")? + 6; - let b = a + text.get(a..)?.find("EVCALC")?; - Some(comment.start + a..comment.start + b) - }) -} - -/// A place where a Calculate script names a field. -#[derive(Clone, Debug, PartialEq)] -enum Site { - /// A string literal holding one name: `getField("a")`, or an item of an - /// `AFSimple_Calculate` list. - Literal(String), - /// An `AFSimple_Calculate` list written as one string of comma-separated names. - Commas(String), - /// A name in the simplified field notation of a `BVCALC` comment. - Notation(String), -} - -impl Site { - fn names(&self) -> Vec<&str> { - match self { - Site::Literal(name) | Site::Notation(name) => vec![name.as_str()], - Site::Commas(list) => list.split(',').map(str::trim).filter(|n| !n.is_empty()).collect(), - } - } - - /// The site written again with field `old` named `new`, or `None` when it doesn't name `old`. - fn renamed(&self, old: &str, new: &str) -> Option { - match self { - Site::Literal(name) => (name == old).then(|| js_str(new)), - Site::Notation(name) => (name == old).then(|| notation_name(new)), - Site::Commas(list) => { - let parts: Vec<&str> = list.split(',').collect(); - parts.iter().any(|p| p.trim() == old).then(|| { - // The spaces around each name stay as they were. - let parts: Vec = parts.iter().map(|p| if p.trim() == old { p.replacen(old, new, 1) } else { (*p).to_owned() }).collect(); - js_str(&parts.join(",")) - }) - } - } - } -} - -/// Where a Calculate script names fields, in order: the simplified field notation in its comment -/// (see [`notation_range`]; [`parse_calculate`] reads it there too), the field list of -/// `AFSimple_Calculate`, and every `getField` whose whole argument is one string literal. -/// A name built at run time (`getField("row" + i)`) is not a site. -fn calculation_sites(js: &str) -> Vec<(Range, Site)> { - let mut sites = Vec::new(); - let (tokens, comments) = js_tokens(js); - if let Some(at) = notation_range(js, &comments) - && let Some(expr) = js.get(at.clone()) - { - for (range, token) in notation_tokens(expr) { - if let Notation::Name(name) = token { - sites.push((at.start + range.start..at.start + range.end, Site::Notation(name))); - } - } - } - let ident = |k: usize, word: &str| tokens.get(k).is_some_and(|(range, t)| *t == Js::Ident && js.get(range.clone()) == Some(word)); - let punct = |k: usize, c: char| tokens.get(k).is_some_and(|(_, t)| *t == Js::Punct(c)); - let string = |k: usize| match tokens.get(k) { - Some((range, Js::Str(text))) => Some((range.clone(), text.clone())), - _ => None, - }; - for k in 0..tokens.len() { - if ident(k, "getField") - && punct(k + 1, '(') - && punct(k + 3, ')') - && let Some((range, name)) = string(k + 2) - { - sites.push((range, Site::Literal(name))); - } - if !(ident(k, "AFSimple_Calculate") && punct(k + 1, '(') && string(k + 2).is_some() && punct(k + 3, ',')) { - continue; - } - if let Some((range, list)) = string(k + 4) { - sites.push((range, Site::Commas(list))); - continue; - } - // `new Array("a", "b")` or `["a", "b"]`: its items, up to the first that isn't a string. - // Nothing else is read, so no token is read for two calls. - let first = if punct(k + 4, '[') { - k + 5 - } else if ident(k + 4, "new") && ident(k + 5, "Array") && punct(k + 6, '(') { - k + 7 - } else { - continue; - }; - for (range, token) in tokens.iter().skip(first) { - match token { - Js::Str(name) => sites.push((range.clone(), Site::Literal(name.clone()))), - Js::Punct(',') => {} - _ => break, - } - } - } - sites.sort_by_key(|(range, _)| range.start); - sites -} - -/// A Calculate script with field `old` named `new` everywhere it reads it (see -/// [`calculation_sites`]), the rest kept as written; `None` when it doesn't read `old`. Also -/// `None` when the new name would read as more than a name there (one holding `EVCALC` would end -/// the notation early), so a script is never left meaning something else. -pub fn rename_in_calculation(js: &str, old: &str, new: &str) -> Option { - let sites = calculation_sites(js); - let edits: Vec<(Range, String)> = sites.iter().filter_map(|(range, site)| Some((range.clone(), site.renamed(old, new)?))).collect(); - if edits.is_empty() { - return None; - } - let out = splice(js, edits); - let expected: Vec<&str> = sites.iter().flat_map(|(_, site)| site.names()).map(|n| if n == old { new } else { n }).collect(); - let written = calculation_sites(&out); - let got: Vec<&str> = written.iter().flat_map(|(_, site)| site.names()).collect(); - (got == expected).then_some(out) -} - -/// The fields a Calculate script reads by name (see [`calculation_sites`]), each once, in the -/// order it first names them. -pub fn calculation_reads(js: &str) -> Vec { - let sites = calculation_sites(js); - unique(sites.iter().flat_map(|(_, site)| site.names()).map(str::to_owned)) -} - -impl Calculate { - /// The fields it reads, each once, in the order it first names them. A name also stands for - /// the fields under it (`row` for `row.0` and `row.1`), as [`calculate`] reads them. - pub fn reads(&self) -> Vec { - match self { - Calculate::None => Vec::new(), - Calculate::Simple { fields, .. } => unique(fields.iter().cloned()), - Calculate::Notation(expr) => unique(notation_tokens(expr).into_iter().filter_map(|(_, token)| match token { - Notation::Name(name) => Some(name), - _ => None, - })), - } - } -} - -/// `names` without repeats, in order. -fn unique(names: impl IntoIterator) -> Vec { - let mut seen = std::collections::HashSet::new(); - names.into_iter().filter(|n| seen.insert(n.clone())).collect() -} - -// ── running them ──────────────────────────────────────────────────────────────────────────── - -/// AFMakeNumber: a number from typed text (currency symbols, group separators, spaces and a -/// trailing % ignored; a comma decimal separator accepted). -pub fn make_number(s: &str) -> Option { - let t: String = s.trim().chars().filter(|c| !c.is_whitespace() && !matches!(c, '$' | '€' | '£' | '¥' | '\'' | '%')).collect(); - if t.is_empty() { - return None; - } - let neg = t.starts_with('(') && t.ends_with(')'); - let t = t.trim_start_matches('(').trim_end_matches(')'); - // "1.234,56" or "1234,56": comma is the decimal separator when it comes last. - let normalized = match (t.rfind(','), t.rfind('.')) { - (Some(c), Some(d)) if c > d => t.replace('.', "").replace(',', "."), - (Some(c), None) if t.len() - c - 1 != 3 => t.replace(',', "."), - _ => t.replace(',', ""), - }; - let v: f64 = normalized.parse().ok()?; - v.is_finite().then_some(if neg { -v } else { v }) -} - -/// A number as JavaScript prints it (integers without a fraction). -pub fn js_number(v: f64) -> String { - if v.fract() == 0.0 && v.abs() < 1e15 { format!("{}", v as i64) } else { format!("{v}") } -} - -fn group(int: &str, sep: char) -> String { - let mut out = String::new(); - for (i, c) in int.chars().enumerate() { - if i > 0 && (int.len() - i).is_multiple_of(3) { - out.push(sep); - } - out.push(c); - } - out -} - -/// A number with `decimals` places in separator style `sep` (0 "1,234.56", 1 "1234.56", -/// 2 "1.234,56", 3 "1234,56", 4 "1'234.56"). -fn number_text(v: f64, decimals: u8, sep: u8) -> String { - // Round half away from zero, as JavaScript's toFixed does for these values. - let p = 10f64.powi(i32::from(decimals)); - let scaled = v.abs() * p; - // Past 2^53 every value is already whole, and scaling could overflow to infinity. - let r = if scaled.is_finite() { scaled.round() / p } else { v.abs() }; - let s = format!("{:.*}", decimals as usize, r); - let (int, frac) = s.split_once('.').map_or((s.as_str(), ""), |(a, b)| (a, b)); - let (g, d) = match sep { - 1 => (None, '.'), - 2 => (Some('.'), ','), - 3 => (None, ','), - 4 => (Some('\''), '.'), - _ => (Some(','), '.'), - }; - let mut out = match g { - Some(c) => group(int, c), - None => int.to_string(), - }; - if !frac.is_empty() { - out.push(d); - out.push_str(frac); - } - out -} - -const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; -const DAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; - -/// A date and time read from text. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct DateTime { - pub y: i32, - pub m: u32, - pub d: u32, - pub hh: u32, - pub mm: u32, - pub ss: u32, -} - -fn days_in(y: i32, m: u32) -> u32 { - match m { - 2 if (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 => 29, - 2 => 28, - 4 | 6 | 9 | 11 => 30, - _ => 31, - } -} - -fn weekday(y: i32, m: u32, d: u32) -> usize { - // Sakamoto's method, in i64 so no year or day can overflow it. - const T: [i64; 12] = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4]; - let y = i64::from(y) - i64::from(m < 3); - let t = T.get((m as usize).wrapping_sub(1)).copied().unwrap_or(0); - (y + y / 4 - y / 100 + y / 400 + t + i64::from(d)).rem_euclid(7) as usize -} - -/// One field of a date format, in the order the format has them. -struct Part { - /// `'y'`, `'m'`, `'d'`, `'H'`, `'M'` or `'s'`; a run of literal digits (the fixed year in - /// "dd.mm.26") is `'#'`. - kind: char, - /// The value of a `'#'` run; 0 for the others. - literal: u32, - /// The digits the field takes when typed run together with the next one: 4 for "yyyy", 2 for - /// "yy", "mm", "dd", "HH", "MM" and "ss". 0 when the width varies ("m", "d") or the field is - /// a name ("mmm"). - width: usize, - /// The next field follows with nothing between them ("yyyymmdd"), so one run of typed digits - /// can hold both. - joined: bool, -} - -/// The fields of a format (year, month, day, hours, minutes, seconds and literal digits), in -/// order. -fn order(fmt: &str) -> Vec { - let mut out: Vec = Vec::new(); - // Weekday names (ddd, dddd) are shown, not typed: leave them out. - let mut cleaned = fmt.replace("dddd", " "); - cleaned = cleaned.replace("ddd", " "); - let mut prev = ' '; - // The letter of the last field's current run and its length so far. - let mut run: Option<(char, usize)> = None; - let fixed = |k: char, n: usize| match (k, n) { - ('y', 2 | 4) | ('m' | 'd' | 'H' | 'M' | 's', 2) => n, - _ => 0, - }; - for c in cleaned.chars() { - let continues = run.is_some_and(|(r, _)| r == c); - if let Some((r, n)) = run - && !continues - { - // The run ended; it was the last field's width. - if let Some(last) = out.last_mut().filter(|p| p.kind != '#') { - last.width = fixed(if r == 'h' { 'H' } else { r }, n); - } - run = None; - } - if let Some(digit) = c.to_digit(10) { - match out.last_mut() { - Some(p) if p.kind == '#' && prev.is_ascii_digit() => p.literal = p.literal.saturating_mul(10).saturating_add(digit), - _ => out.push(Part { kind: '#', literal: digit, width: 0, joined: false }), - } - prev = c; - continue; - } - let k = match c { - 'y' => 'y', - 'm' => 'm', - 'd' => 'd', - 'H' | 'h' => 'H', - 'M' => 'M', - 's' => 's', - _ => { - prev = c; - continue; - } - }; - if continues { - run = run.map(|(r, n)| (r, n.saturating_add(1))); - } else { - // A field starting right where the last one's letters stopped is joined to it. - let adjacent = matches!(prev, 'y' | 'm' | 'd' | 'H' | 'h' | 'M' | 's'); - if out.last().map(|p| p.kind) != Some(k) { - if let Some(last) = out.last_mut().filter(|p| p.kind != '#') { - last.joined = adjacent; - } - out.push(Part { kind: k, literal: 0, width: 0, joined: false }); - } - run = Some((c, 1)); - } - prev = c; - } - if let (Some((r, n)), Some(last)) = (run, out.last_mut().filter(|p| p.kind != '#')) { - last.width = fixed(if r == 'h' { 'H' } else { r }, n); - } - out -} - -/// Parse typed text against a date/time format (AFDate_KeystrokeEx's leniency: any -/// separators, month names, two-digit years). `None` when it isn't a valid date. -pub fn parse_date(text: &str, fmt: &str) -> Option { - let t = text.trim(); - if t.is_empty() { - return None; - } - let lower = t.to_lowercase(); - // A bare trailing "p" ("5:30p") only counts when it follows a digit, not a word like "Sep". - let bare_p = lower.strip_suffix('p').is_some_and(|rest| rest.trim_end().ends_with(|c: char| c.is_ascii_digit())); - let pm = lower.contains("pm") || bare_p; - let bare_a = lower.strip_suffix('a').is_some_and(|rest| rest.trim_end().ends_with(|c: char| c.is_ascii_digit())); - let am = lower.contains("am") || bare_a; - // Tokens: runs of digits (kept as text, so a run can be split between fields typed without - // separators) and month names. - let mut nums: VecDeque = VecDeque::new(); - let mut month_name: Option = None; - let mut cur = String::new(); - let flush = |cur: &mut String, nums: &mut VecDeque, month: &mut Option| { - if cur.is_empty() { - return; - } - if cur.starts_with(|c: char| c.is_ascii_digit()) { - nums.push_back(std::mem::take(cur)); - } else if cur.len() >= 3 - && let Some(i) = MONTHS.iter().position(|m| m.to_lowercase().starts_with(&cur.to_lowercase())) - { - *month = Some(i as u32 + 1); - } - cur.clear(); - }; - for c in t.chars() { - // Digits and letters form separate tokens ("5Mar" is 5 and Mar). - let same_kind = cur.is_empty() || cur.chars().next().is_some_and(|x| x.is_ascii_digit()) == c.is_ascii_digit(); - if c.is_alphanumeric() && same_kind { - cur.push(c); - } else { - flush(&mut cur, &mut nums, &mut month_name); - if c.is_alphanumeric() { - cur.push(c); - } - } - } - flush(&mut cur, &mut nums, &mut month_name); - let mut dt = DateTime { y: 0, m: 1, d: 1, hh: 0, mm: 0, ss: 0 }; - // The next number for a field. A field joined to the next one in the format ("yyyymmdd") - // takes only its own width from a longer run of digits and leaves the rest for the next. - // A run typed on its own is split only when fewer runs were typed than the fields of its kind - // still to fill (`need`, this one included: date fields for a date field, time fields for a - // time field, since times may be left out), and only when its rest can fill the next field - // (`next_width`): with separators each run is one field's number, so "2026-10-18" keeps its - // four-digit year in a yymmdd or yymmddHHMM field, and "2026 10 018" is the 18th. What is left - // of a split run (`tail`: the front of `nums` is one) goes on being split along the fields - // written side by side, and must fill its last field exactly, so "202610018" leaves "018" for - // dd and is refused. `None` when there is no number left, it doesn't fit a u32, or such a - // rest has the wrong width. - let next_number = |nums: &mut VecDeque, tail: &mut bool, part: &Part, need: usize, next_width: usize| -> Option { - let run = nums.pop_front()?; - let was_tail = std::mem::replace(tail, false); - let short = nums.len() < need.saturating_sub(1); - let digits = match (run.get(..part.width), run.get(part.width..)) { - (Some(head), Some(rest)) if part.joined && part.width > 0 && !rest.is_empty() && (was_tail || (short && rest.len() >= next_width)) => { - nums.push_front(rest.to_string()); - *tail = true; - head - } - _ if was_tail && part.width > 0 && run.len() != part.width => return None, - _ => run.as_str(), - }; - digits.parse::().ok() - }; - let parts = order(fmt); - let takes_number = |p: &Part| matches!(p.kind, 'y' | 'd' | 'H' | 'M' | 's') || (p.kind == 'm' && month_name.is_none()); - let is_time = |p: &Part| matches!(p.kind, 'H' | 'M' | 's'); - // The fields of each kind still to fill: [date, time]. - let mut wanted = [parts.iter().filter(|p| takes_number(p) && !is_time(p)).count(), parts.iter().filter(|p| is_time(p)).count()]; - let mut tail = false; - let mut have_y = false; - for (i, part) in parts.iter().enumerate() { - let kind = usize::from(is_time(part)); - let need = wanted.get(kind).copied().unwrap_or(0); - let next_width = parts.get(i + 1).map_or(0, |p| p.width); - if takes_number(part) - && let Some(left) = wanted.get_mut(kind) - { - *left = left.saturating_sub(1); - } - let take = |nums: &mut VecDeque, tail: &mut bool| next_number(nums, tail, part, need, next_width); - match part.kind { - // Literal digits in the format may be typed or left out ("15.05.26" or "15.05" - // for "dd.mm.26"); a different number there is left over and rejected below. - '#' if nums.front().and_then(|n| n.parse::().ok()) == Some(part.literal) => { - nums.pop_front(); - tail = false; - } - 'm' => { - dt.m = match month_name { - Some(m) => m, - None => take(&mut nums, &mut tail)?, - } - } - 'd' => dt.d = take(&mut nums, &mut tail)?, - 'y' => { - let y = i32::try_from(take(&mut nums, &mut tail)?).ok()?; - dt.y = if y < 100 { if y < 50 { 2000 + y } else { 1900 + y } } else { y }; - have_y = true; - } - // Times may be left out; a number that is there must be readable. - 'H' => dt.hh = if nums.is_empty() { 0 } else { take(&mut nums, &mut tail)? }, - 'M' => dt.mm = if nums.is_empty() { 0 } else { take(&mut nums, &mut tail)? }, - 's' => dt.ss = if nums.is_empty() { 0 } else { take(&mut nums, &mut tail)? }, - _ => {} - } - } - if !nums.is_empty() { - return None; - } - if !have_y { - dt.y = 2000; - } - if pm && dt.hh < 12 { - dt.hh += 12; - } - if am && dt.hh == 12 { - dt.hh = 0; - } - let has_date = fmt.contains(['y', 'm', 'd']); - if has_date && (!(1..=12).contains(&dt.m) || dt.d == 0 || dt.d > days_in(dt.y, dt.m)) { - return None; - } - if dt.hh > 23 || dt.mm > 59 || dt.ss > 59 { - return None; - } - Some(dt) -} - -/// Format a date/time with Acrobat's date format letters. -pub fn format_date(dt: DateTime, fmt: &str) -> String { - let chars: Vec = fmt.chars().collect(); - let mut out = String::new(); - let mut i = 0; - while i < chars.len() { - let c = chars[i]; - let mut n = 1; - while i + n < chars.len() && chars[i + n] == c { - n += 1; - } - let h12 = if dt.hh.is_multiple_of(12) { 12 } else { dt.hh % 12 }; - match (c, n) { - ('y', 4..) => { - let _ = write!(out, "{:04}", dt.y); - } - ('y', _) => { - let _ = write!(out, "{:02}", dt.y.rem_euclid(100)); - } - ('m', 3..) => match MONTHS.get((dt.m as usize).wrapping_sub(1)) { - Some(name) if n >= 4 => out.push_str(name), - Some(name) => out.push_str(name.get(..3).unwrap_or(name)), - None => { - let _ = write!(out, "{}", dt.m); - } - }, - ('m', 2) => { - let _ = write!(out, "{:02}", dt.m); - } - ('m', _) => { - let _ = write!(out, "{}", dt.m); - } - ('d', 3..) => { - let name = DAYS.get(weekday(dt.y, dt.m, dt.d)).copied().unwrap_or_default(); - out.push_str(if n >= 4 { name } else { name.get(..3).unwrap_or(name) }); - } - ('d', 2) => { - let _ = write!(out, "{:02}", dt.d); - } - ('d', _) => { - let _ = write!(out, "{}", dt.d); - } - ('H', 2) => { - let _ = write!(out, "{:02}", dt.hh); - } - ('H', _) => { - let _ = write!(out, "{}", dt.hh); - } - ('h', 2) => { - let _ = write!(out, "{h12:02}"); - } - ('h', _) => { - let _ = write!(out, "{h12}"); - } - ('M', 2) => { - let _ = write!(out, "{:02}", dt.mm); - } - ('M', _) => { - let _ = write!(out, "{}", dt.mm); - } - ('s', 2) => { - let _ = write!(out, "{:02}", dt.ss); - } - ('s', _) => { - let _ = write!(out, "{}", dt.ss); - } - ('t', 2) => out.push_str(if dt.hh < 12 { "am" } else { "pm" }), - ('t', _) => out.push(if dt.hh < 12 { 'a' } else { 'p' }), - (c, n) => out.extend(std::iter::repeat_n(c, n)), - } - i += n; - } - out -} - -const SPECIAL_MASKS: [&str; 4] = ["99999", "99999-9999", "(999) 999-9999", "999-99-9999"]; - -fn mask_fits(mask: &str, text: &str) -> bool { - let t: Vec = text.chars().collect(); - let m: Vec = mask.chars().collect(); - t.len() == m.len() - && m.iter().zip(&t).all(|(mc, tc)| match mc { - '9' => tc.is_ascii_digit(), - 'A' => tc.is_alphabetic(), - 'O' => tc.is_alphanumeric(), - 'X' => true, - other => other == tc, - }) -} - -/// Fill a mask from the typed characters that fit its slots. -fn apply_mask(mask: &str, text: &str) -> Option { - let mut src = text.chars().filter(|c| c.is_alphanumeric()); - let mut out = String::new(); - for mc in mask.chars() { - match mc { - '9' | 'A' | 'O' | 'X' => { - let c = src.next()?; - let ok = match mc { - '9' => c.is_ascii_digit(), - 'A' => c.is_alphabetic(), - _ => true, - }; - if !ok { - return None; - } - out.push(c); - } - other => out.push(other), - } - } - src.next().is_none().then_some(out) -} - -/// The text shown for a value (the Format event). -pub fn format_value(f: &Format, value: &str) -> String { - if value.is_empty() { - return String::new(); - } - match f { - Format::None => value.to_string(), - Format::Number { decimals, sep, neg, currency, prepend } => { - let Some(v) = make_number(value) else { return value.to_string() }; - let body = number_text(v, *decimals, *sep); - let body = match (currency.is_empty(), *prepend) { - (true, _) => body, - (false, true) => format!("{currency}{body}"), - (false, false) => format!("{body}{currency}"), - }; - match (v < 0.0, neg) { - (false, _) => body, - // Styles 2 and 3 use parentheses (3 is also red, which the appearance can't - // know here); 0 and 1 a minus sign. - (true, 2 | 3) => format!("({body})"), - (true, _) => format!("-{body}"), - } - } - Format::Percent { decimals, sep } => match make_number(value) { - Some(v) => format!( - "{}%", - if v < 0.0 { format!("-{}", number_text(v * 100.0, *decimals, *sep)) } else { number_text(v * 100.0, *decimals, *sep) } - ), - None => value.to_string(), - }, - Format::Date(fmt) | Format::Time(fmt) => parse_date(value, fmt).map_or_else(|| value.to_string(), |dt| format_date(dt, fmt)), - Format::Special(n) => apply_mask(SPECIAL_MASKS[(*n).min(3) as usize], value).unwrap_or_else(|| value.to_string()), - Format::Mask(m) => apply_mask(m, value).unwrap_or_else(|| value.to_string()), - } -} - -/// The Keystroke event on commit: does the value fit the format? Returns the value to store -/// (numbers are normalised), or Acrobat's message. -pub fn keystroke(f: &Format, field: &str, value: &str) -> Result { - let bad = || format!("The value entered does not match the format of the field [ {field} ]"); - // Blank input clears a formatted field; without a format, whitespace is text like any other. - if *f != Format::None && value.trim().is_empty() { - return Ok(String::new()); - } - match f { - Format::None => Ok(value.to_string()), - Format::Number { .. } => make_number(value).map(js_number).ok_or_else(bad), - Format::Percent { .. } => { - // Typed "12.5%" or "12.5" both mean 0.125 once stored? Acrobat stores the number - // as typed and shows it ×100: keep the typed number. - make_number(value).map(js_number).ok_or_else(bad) - } - Format::Date(fmt) | Format::Time(fmt) => parse_date(value, fmt) - .map(|_| value.trim().to_string()) - .ok_or_else(|| format!("Invalid date/time: please ensure that the date/time exists. Field [ {field} ] should match format {fmt}")), - Format::Special(n) => { - let m = SPECIAL_MASKS[(*n).min(3) as usize]; - apply_mask(m, value).filter(|s| mask_fits(m, s)).map(|_| value.trim().to_string()).ok_or_else(bad) - } - Format::Mask(m) => apply_mask(m, value).map(|_| value.trim().to_string()).ok_or_else(bad), - } -} - -/// The Validate event: `Err` with Acrobat's message when the value is out of range. -pub fn validate(v: &Validate, value: &str) -> Result<(), String> { - let Validate::Range { min, max } = v else { return Ok(()) }; - if value.trim().is_empty() { - return Ok(()); - } - let Some(x) = make_number(value) else { return Ok(()) }; - let fmt = |n: f64| js_number(n); - let ok = min.is_none_or(|m| x >= m) && max.is_none_or(|m| x <= m); - if ok { - return Ok(()); - } - Err(match (min, max) { - (Some(a), Some(b)) => format!("Invalid value: must be greater than or equal to {} and less than or equal to {}.", fmt(*a), fmt(*b)), - (Some(a), None) => format!("Invalid value: must be greater than or equal to {}.", fmt(*a)), - (None, Some(b)) => format!("Invalid value: must be less than or equal to {}.", fmt(*b)), - (None, None) => String::new(), - }) -} - -/// A token of simplified field notation. -#[derive(Clone, Debug, PartialEq)] -enum Notation { - /// A number, as written. - Number, - /// `+ - * / ( )`. - Op(char), - /// A field name, its `\` escapes undone. - Name(String), -} - -/// Simplified field notation's tokens, each with its byte range in `src`. Whitespace separates -/// them, and `\` escapes a space or an operator inside a name, as Acrobat writes them. -fn notation_tokens(src: &str) -> Vec<(Range, Notation)> { - let breaks = |c: char| c.is_whitespace() || "+-*/()".contains(c); - let mut out = Vec::new(); - let mut cs = src.char_indices().peekable(); - while let Some(&(start, c)) = cs.peek() { - let mut end = start + c.len_utf8(); - if c.is_whitespace() { - cs.next(); - } else if breaks(c) { - cs.next(); - out.push((start..end, Notation::Op(c))); - } else if c.is_ascii_digit() || c == '.' { - while let Some((i, d)) = cs.next_if(|(_, d)| d.is_ascii_digit() || *d == '.') { - end = i + d.len_utf8(); - } - out.push((start..end, Notation::Number)); - } else { - let mut name = String::new(); - while let Some((i, n)) = cs.next_if(|(_, n)| !breaks(*n)) { - end = i + n.len_utf8(); - if n == '\\' - && let Some((j, next)) = cs.next() - { - end = j + next.len_utf8(); - name.push(next); - } else { - name.push(n); - } - } - out.push((start..end, Notation::Name(name))); - } - } - out -} - -/// A field name as simplified field notation writes it: `\` before a space, an operator or a -/// `\`, and before a first character that would start a number. -fn notation_name(name: &str) -> String { - let mut out = String::new(); - for (i, c) in name.chars().enumerate() { - if c.is_whitespace() || "+-*/()\\".contains(c) || (i == 0 && (c.is_ascii_digit() || c == '.')) { - out.push('\\'); - } - out.push(c); - } - out -} - -/// `src` with each range replaced. The ranges come in order and don't overlap; one that does -/// is left out rather than written over another. -fn splice(src: &str, edits: impl IntoIterator, String)>) -> String { - let mut out = String::with_capacity(src.len()); - let mut done = 0; - for (range, with) in edits { - let Some(kept) = src.get(done..range.start).filter(|_| range.start <= range.end) else { continue }; - out.push_str(kept); - out.push_str(&with); - done = range.end; - } - out.push_str(src.get(done..).unwrap_or_default()); - out -} - -/// Simplified field notation as the JavaScript Acrobat writes after its `BVCALC` comment: -/// each field name becomes `AFMakeNumber(this.getField("name").value)`, the rest stays. -pub fn notation_js(expr: &str) -> String { - let names = notation_tokens(expr).into_iter().filter_map(|(range, t)| match t { - Notation::Name(name) => Some((range, format!("AFMakeNumber(this.getField({}).value)", js_str(&name)))), - _ => None, - }); - splice(expr, names) -} - -/// Evaluate simplified field notation: + - * / and parentheses over numbers and field names. -fn eval_notation(src: &str, value_of: &dyn Fn(&str) -> f64) -> Option { - #[derive(Debug, Clone, PartialEq)] - enum T { - N(f64), - Op(char), - } - let toks = notation_tokens(src) - .into_iter() - .map(|(range, t)| match t { - Notation::Number => src.get(range)?.parse().ok().map(T::N), - Notation::Op(c) => Some(T::Op(c)), - Notation::Name(name) => Some(T::N(value_of(&name))), - }) - .collect::>>()?; - // Recursive descent, `d` levels deep (at most MAX_NESTING). - fn expr(t: &[T], i: &mut usize, d: usize) -> Option { - let mut v = term(t, i, d)?; - while let Some(T::Op(o @ ('+' | '-'))) = t.get(*i) { - *i += 1; - let r = term(t, i, d)?; - v = if *o == '+' { v + r } else { v - r }; - } - Some(v) - } - fn term(t: &[T], i: &mut usize, d: usize) -> Option { - let mut v = factor(t, i, d)?; - while let Some(T::Op(o @ ('*' | '/'))) = t.get(*i) { - *i += 1; - let r = factor(t, i, d)?; - v = if *o == '*' { v * r } else { v / r }; - } - Some(v) - } - fn factor(t: &[T], i: &mut usize, d: usize) -> Option { - if d > MAX_NESTING { - return None; - } - match t.get(*i)? { - T::N(n) => { - *i += 1; - Some(*n) - } - T::Op('-') => { - *i += 1; - factor(t, i, d + 1).map(|v| -v) - } - T::Op('(') => { - *i += 1; - let v = expr(t, i, d + 1)?; - (t.get(*i) == Some(&T::Op(')'))).then(|| *i += 1)?; - Some(v) - } - _ => None, - } - } - let mut i = 0; - let v = expr(&toks, &mut i, 0)?; - (i == toks.len() && v.is_finite()).then_some(v) -} - -/// The Calculate event: the new value, given the current values of the other fields -/// (`values(name)` returns every field named `name` or below it, for groups). -pub fn calculate(c: &Calculate, values: &dyn Fn(&str) -> Vec) -> Option { - let num = |s: &String| make_number(s).unwrap_or(0.0); - match c { - Calculate::None => None, - Calculate::Simple { op, fields } => { - let xs: Vec = fields.iter().flat_map(|f| values(f)).map(|v| num(&v)).collect(); - let v = match op { - CalcOp::Sum => xs.iter().sum(), - CalcOp::Product => xs.iter().product(), - CalcOp::Average => { - if xs.is_empty() { - 0.0 - } else { - xs.iter().sum::() / xs.len() as f64 - } - } - CalcOp::Minimum => xs.iter().copied().fold(f64::INFINITY, f64::min), - CalcOp::Maximum => xs.iter().copied().fold(f64::NEG_INFINITY, f64::max), - }; - Some(if v.is_finite() { js_number(v) } else { "0".into() }) - } - Calculate::Notation(expr) => { - let v = eval_notation(expr, &|name| values(name).first().map(num).unwrap_or(0.0))?; - Some(js_number(v)) - } - } -} - #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn javascript_escapes_are_decoded() { - assert_eq!( - parse_format("AFNumber_Format(2, 0, 0, 0, \"\\x24\", true);"), - Some(Format::Number { decimals: 2, sep: 0, neg: 0, currency: "$".into(), prepend: true }) - ); - assert_eq!(parse_format("AFDate_FormatEx(\"\\b\\f\\v\\0\\q\");"), Some(Format::Date("\u{8}\u{c}\u{b}\0q".into()))); - } - - #[test] - fn scripts_are_recognised_and_written_back() { - assert_eq!( - parse_format("AFNumber_Format(2, 0, 0, 0, \"$\", true);"), - Some(Format::Number { decimals: 2, sep: 0, neg: 0, currency: "$".into(), prepend: true }) - ); - assert_eq!(parse_format("AFPercent_Format(1, 0);"), Some(Format::Percent { decimals: 1, sep: 0 })); - assert_eq!(parse_format("AFDate_FormatEx(\"dd-mmm-yyyy\");"), Some(Format::Date("dd-mmm-yyyy".into()))); - assert_eq!(parse_format("AFDate_Format(2);"), Some(Format::Date("mm/dd/yy".into()))); - assert_eq!(parse_format("AFTime_Format(1);"), Some(Format::Time("h:MM tt".into()))); - assert_eq!(parse_format("AFSpecial_Format(2);"), Some(Format::Special(2))); - assert_eq!(parse_format("AFSpecial_KeystrokeEx(\"AA-9999\");"), Some(Format::Mask("AA-9999".into()))); - assert_eq!(parse_format("app.alert('hi')"), None); - assert_eq!(parse_validate("AFRange_Validate(true, 0, true, 100);"), Some(Validate::Range { min: Some(0.0), max: Some(100.0) })); - assert_eq!(parse_validate("AFRange_Validate(false, 0, true, 10);"), Some(Validate::Range { min: None, max: Some(10.0) })); - assert_eq!( - parse_calculate("AFSimple_Calculate(\"SUM\", new Array (\"a\", \"b.c\"));"), - Some(Calculate::Simple { op: CalcOp::Sum, fields: vec!["a".into(), "b.c".into()] }) - ); - assert_eq!(parse_calculate("AFSimple_Calculate('AVG', ['x']);"), Some(Calculate::Simple { op: CalcOp::Average, fields: vec!["x".into()] })); - assert_eq!( - parse_calculate("/** BVCALC Price * Qty EVCALC **/ event.value = AFMakeNumber(getField(\"Price\").value) * …"), - Some(Calculate::Notation("Price * Qty".into())) - ); - // Round trips through what the tabs write. - for f in [ - Format::Number { decimals: 2, sep: 2, neg: 2, currency: "€".into(), prepend: false }, - Format::Percent { decimals: 0, sep: 0 }, - Format::Date("mmmm d, yyyy".into()), - Format::Special(3), - ] { - let (fj, _) = format_js(&f).unwrap(); - assert_eq!(parse_format(&fj), Some(f)); - } - let euro = format_js(&Format::Number { decimals: 2, sep: 2, neg: 0, currency: "€".into(), prepend: true }).unwrap().0; - assert!(euro.contains("\\u20AC"), "{euro}"); - assert!(!euro.contains('€'), "{euro}"); - let pound = format_js(&Format::Number { decimals: 2, sep: 0, neg: 0, currency: "£".into(), prepend: true }).unwrap().0; - assert!(pound.contains("\\u00A3"), "{pound}"); - assert!(!pound.contains('£'), "{pound}"); - let c = Calculate::Simple { op: CalcOp::Product, fields: vec!["q".into(), "p".into()] }; - assert_eq!(parse_calculate(&calculate_js(&c).unwrap()), Some(c)); - let c = Calculate::Notation("a + b".into()); - assert_eq!(parse_calculate(&calculate_js(&c).unwrap()), Some(c)); - // The JavaScript after the comment is Acrobat's translation, so viewers that run it - // instead of reading the comment compute the same value (not 0). - assert_eq!( - calculate_js(&Calculate::Notation("(Unit\\ Price * Qty) / 2".into())).unwrap(), - "/** BVCALC (Unit\\ Price * Qty) / 2 EVCALC **/ event.value = (AFMakeNumber(this.getField(\"Unit Price\").value) * AFMakeNumber(this.getField(\"Qty\").value)) / 2;" - ); - } - - #[test] - fn a_rename_rewrites_only_where_a_calculation_reads_the_field() { - let renamed = |js: &str| rename_in_calculation(js, "a", "qty"); - // The field lists Acrobat and other tools write for AFSimple_Calculate. - assert_eq!( - renamed("AFSimple_Calculate(\"SUM\", new Array (\"a\", \"b\"));").as_deref(), - Some("AFSimple_Calculate(\"SUM\", new Array (\"qty\", \"b\"));") - ); - assert_eq!(renamed("AFSimple_Calculate('AVG', ['b', 'a']);").as_deref(), Some("AFSimple_Calculate('AVG', ['b', \"qty\"]);")); - assert_eq!(renamed("AFSimple_Calculate(\"SUM\", \"a, b,a\");").as_deref(), Some("AFSimple_Calculate(\"SUM\", \"qty, b,qty\");")); - assert_eq!(renamed("AFSimple_Calculate(\"SUM\", new Array(\"ab\", \"b.a\"));"), None, "other names that contain it"); - // Notation: the expression in the comment and Acrobat's translation after it. - let notation = |expr: &str| calculate_js(&Calculate::Notation(expr.into())).unwrap(); - assert_eq!(rename_in_calculation(¬ation("(a + ab) * a / 2"), "a", "Unit Price"), Some(notation("(Unit\\ Price + ab) * Unit\\ Price / 2"))); - assert_eq!(rename_in_calculation(¬ation("Unit\\ Price * 2"), "Unit Price", "price"), Some(notation("price * 2"))); - // Scripts: a getField whose argument is one string literal. Comments, strings, template - // literals, regular expressions and names built at run time are left as written. - let script = "// getField(\"a\")\nvar s = \"getField('a')\", t = `getField(\"a\")`; /* getField(\"a\") */\n\ - if (/\"/.test(s)) event.value = this.getField('a').value / 2 * getField(\"a\" + 1).value + getField ( \"ab\" ).value;"; - assert_eq!(renamed(script), Some(script.replacen("this.getField('a')", "this.getField(\"qty\")", 1))); - assert_eq!(renamed("event.value = getField(\"b\").value;"), None); - // A new name is escaped as the script needs it. - assert_eq!( - rename_in_calculation("event.value = getField(\"a\").value;", "a", "say \"é\"").as_deref(), - Some("event.value = getField(\"say \\\"\\u00E9\\\"\").value;") - ); - // A name that would end the notation early is not written in. - assert_eq!(rename_in_calculation(¬ation("a * 2"), "a", "qtyEVCALC"), None); - // Unfinished scripts end their tokens, not the program. - for js in ["getField(\"a", "/* getField(\"a\")", "`getField(\"a\")", "x = /[/", "getField('a\\u12"] { - assert_eq!(renamed(js), None, "{js}"); - } - } - - /// The notation is read only from a comment, where Acrobat writes it: a string or code that - /// spells the markers is left alone, by the Calculate tab and by a rename. - #[test] - fn notation_is_read_only_from_a_comment() { - let string = "var s = \"BVCALC a EVCALC\"; event.value = getField(\"b\").value;"; - assert_eq!(parse_calculate(string), None); - assert_eq!(rename_in_calculation(string, "a", "qty"), None); - // A comment after such a string holds the notation. - let both = "var s = 'BVCALC x EVCALC'; /** BVCALC a + 1 EVCALC **/ event.value = 1;"; - assert_eq!(parse_calculate(both), Some(Calculate::Notation("a + 1".into()))); - assert_eq!(rename_in_calculation(both, "x", "y"), None); - assert_eq!(rename_in_calculation(both, "a", "qty"), Some(both.replace("a + 1", "qty + 1"))); - // A line comment is a comment too, and an unfinished one still holds it. - assert_eq!(parse_calculate("// BVCALC a EVCALC\nevent.value = 1;"), Some(Calculate::Notation("a".into()))); - assert_eq!(parse_calculate("/** BVCALC a EVCALC"), Some(Calculate::Notation("a".into()))); - } - - /// A line comment ends at any of JavaScript's line terminators, not only `\n`, so with `\r` line - /// endings the code after one is still code; and a `/` read as starting a regular expression - /// divides after all when the line ends first. - #[test] - fn a_line_comment_ends_at_any_line_terminator() { - for end in ["\n", "\r", "\r\n", "\u{2028}", "\u{2029}"] { - let string = format!("// header{end}var s = \"BVCALC a EVCALC\"; event.value = getField(\"b\").value;"); - assert_eq!(parse_calculate(&string), None, "{end:?}"); - assert_eq!(rename_in_calculation(&string, "a", "qty"), None, "{end:?}"); - let script = format!("// note{end}getField(\"a\").value;"); - assert_eq!(rename_in_calculation(&script, "a", "qty"), Some(script.replace("\"a\"", "\"qty\"")), "{end:?}"); - let divides = format!("var n = i++ / 2;{end}event.value = getField(\"a\").value / 4;"); - assert_eq!(rename_in_calculation(÷s, "a", "qty"), Some(divides.replace("\"a\"", "\"qty\"")), "{end:?}"); - } - } - - /// U+2028 and U+2029 end a word, a number or a regular expression's flags, though as bytes - /// past ASCII they could be part of one, so a `getField` right after one is still a call. - #[test] - fn a_token_ends_at_a_line_separator() { - for end in ["\u{2028}", "\u{2029}"] { - let scripts = ["x", "1", "/r/g"].map(|token| format!("var t = {token}{end}getField(\"a\").value;")); - let renamed = scripts.clone().map(|js| rename_in_calculation(&js, "a", "qty")); - assert_eq!(renamed, scripts.map(|js| Some(js.replace("\"a\"", "\"qty\""))), "{end:?}"); - } - } - - /// A `/` after the condition of `if`, `while`, `for` or `with` starts a regular expression, - /// as after an operator; after a call's parentheses it still divides. - #[test] - fn a_regex_after_a_condition_is_not_read_as_code() { - let renamed = |js: &str| rename_in_calculation(js, "a", "qty"); - assert_eq!(renamed("if (flag) /getField(\"a\")/.test(x); event.value = 1;"), None); - // A quote inside it doesn't start a string that hides the rest of the script. - let quote = "while (i--) /\"/.test(s); event.value = getField(\"a\").value;"; - assert_eq!(renamed(quote), Some(quote.replace("\"a\"", "\"qty\""))); - // The same after the `while` that ends a `do` loop. - let after_do = "do i++; while (i < n) /\"/.test(s); event.value = getField(\"a\").value;"; - assert_eq!(renamed(after_do), Some(after_do.replace("\"a\"", "\"qty\""))); - // After a call, or a method that happens to be called `if`, the `/` divides. - let divides = "event.value = Math.abs(getField(\"b\").value) / 2 + getField(\"a\").value / 3 + o.if(x) / 4 + getField(\"a\").value / 5;"; - assert_eq!(renamed(divides), Some(divides.replace("\"a\"", "\"qty\""))); - } - - /// Scripts are read in one pass: a line of unfinished regular expressions, or calls nested in - /// one another's field lists, don't make a rename read the rest of the script again for each. - #[test] - fn hostile_scripts_are_read_in_one_pass() { - let regexes = format!("x = {} getField(\"a\")", "/[".repeat(500_000)); - assert!(rename_in_calculation(®exes, "a", "b").is_some_and(|js| js.ends_with("getField(\"b\")"))); - let nested = format!("{}\"a\"", "AFSimple_Calculate(\"SUM\", new Array(".repeat(100_000)); - assert_eq!(rename_in_calculation(&nested, "a", "b"), Some(format!("{}\"b\"", "AFSimple_Calculate(\"SUM\", new Array(".repeat(100_000)))); - } - - /// Notation is read through one lexer: field names with escapes, numbers and operators. - #[test] - fn notation_is_read_by_one_lexer() { - let tokens: Vec = notation_tokens("(Unit\\ Price+1.5)*qty_2\\").into_iter().map(|(_, t)| t).collect(); - assert_eq!( - tokens, - [ - Notation::Op('('), - Notation::Name("Unit Price".into()), - Notation::Op('+'), - Notation::Number, - Notation::Op(')'), - Notation::Op('*'), - Notation::Name("qty_2\\".into()) - ] - ); - assert_eq!(notation_js("2 * a\\ b"), "2 * AFMakeNumber(this.getField(\"a b\").value)"); - let values = |name: &str| if name == "Unit Price" { 4.0 } else { 0.5 }; - assert_eq!(eval_notation("(Unit\\ Price + 1.5) * qty", &values), Some(2.75)); - assert_eq!(eval_notation("1.2.3 + a", &values), None, "a number that isn't one"); - assert_eq!(notation_name("2nd + (x)\\"), "\\2nd\\ \\+\\ \\(x\\)\\\\"); - } - - proptest::proptest! { - /// Any field name written in notation reads back as that one name. - #[test] - fn any_name_written_in_notation_reads_back(name in "\\PC{1,12}") { - let written = notation_name(&name); - proptest::prop_assert_eq!(notation_tokens(&written), vec![(0..written.len(), Notation::Name(name))]); - } - - /// Renaming a field in a formula and back gives the formula as it was. - #[test] - fn a_rename_round_trips(old in "\\PC{1,8}", new in "\\PC{1,8}", others in proptest::collection::vec("\\PC{1,8}", 0..4), sum in proptest::bool::ANY) { - proptest::prop_assume!(old != new && !others.contains(&new) && ![&old, &new].into_iter().chain(&others).any(|n| n.contains("VCALC"))); - let names: Vec = std::iter::once(old.clone()).chain(others).collect(); - let js = if sum { - calculate_js(&Calculate::Simple { op: CalcOp::Sum, fields: names }).unwrap() - } else { - calculate_js(&Calculate::Notation(names.iter().map(|n| notation_name(n)).collect::>().join(" * "))).unwrap() - }; - let renamed = rename_in_calculation(&js, &old, &new); - proptest::prop_assert!(renamed.is_some(), "{js}"); - let back = renamed.as_deref().and_then(|r| rename_in_calculation(r, &new, &old)); - proptest::prop_assert_eq!(back, Some(js)); - } - - /// What a formula reads is the same whether it is read from the tab's settings or from - /// the script they are written as. - #[test] - fn a_formula_reads_the_same_fields_as_its_script(names in proptest::collection::vec("\\PC{1,8}", 1..5), sum in proptest::bool::ANY) { - proptest::prop_assume!(!names.iter().any(|n| n.contains("VCALC"))); - let c = if sum { - Calculate::Simple { op: CalcOp::Product, fields: names.clone() } - } else { - Calculate::Notation(names.iter().map(|n| notation_name(n)).collect::>().join(" + ")) - }; - let js = calculate_js(&c).unwrap(); - proptest::prop_assert_eq!(calculation_reads(&js), c.reads()); - proptest::prop_assert_eq!(c.reads(), unique(names)); - } - } - - #[test] - fn a_calculation_reads_each_named_field_once() { - assert_eq!(Calculate::Simple { op: CalcOp::Sum, fields: vec!["a".into(), "b".into(), "a".into()] }.reads(), ["a", "b"]); - assert_eq!(Calculate::Notation("(Unit\\ Price + 2) * qty / qty".into()).reads(), ["Unit Price", "qty"]); - assert_eq!(Calculate::None.reads(), Vec::::new()); - let script = "var t = getField(\"sub\").value; // getField(\"x\")\nevent.value = t + getField(\"tax\").value + getField(\"row\" + 1).value;"; - assert_eq!(calculation_reads(script), ["sub", "tax"], "names built at run time aren't known"); - assert_eq!(calculation_reads("AFSimple_Calculate(\"SUM\", \"a, b\");"), ["a", "b"]); - } - - #[test] - fn numbers_format_like_acrobat() { - let n = |d, s, ng, c: &str, p| Format::Number { decimals: d, sep: s, neg: ng, currency: c.into(), prepend: p }; - assert_eq!(format_value(&n(2, 0, 0, "$", true), "1234.5"), "$1,234.50"); - assert_eq!(format_value(&n(0, 1, 0, "", true), "1234567"), "1234567"); - assert_eq!(format_value(&n(2, 2, 0, " €", false), "1234.5"), "1.234,50 €"); - assert_eq!(format_value(&n(2, 0, 2, "", true), "-12"), "(12.00)"); - assert_eq!(format_value(&n(1, 0, 0, "", true), "-0.25"), "-0.3"); - assert_eq!(format_value(&Format::Percent { decimals: 1, sep: 0 }, "0.125"), "12.5%"); - assert_eq!(make_number("$1,234.50"), Some(1234.5)); - assert_eq!(make_number("1.234,5"), Some(1234.5)); - assert_eq!(make_number("(7)"), Some(-7.0)); - assert_eq!(make_number("abc"), None); - assert_eq!(keystroke(&n(2, 0, 0, "$", true), "Total", "$1,000"), Ok("1000".into())); - assert_eq!( - keystroke(&n(2, 0, 0, "$", true), "Total", "ten"), - Err("The value entered does not match the format of the field [ Total ]".into()) - ); - // Blank input clears a formatted field (#1323); without a format it is text like any other. - assert_eq!(keystroke(&n(2, 0, 0, "$", true), "Total", " \t"), Ok(String::new())); - assert_eq!(keystroke(&Format::None, "Name", " \t"), Ok(" \t".into())); - } - - #[test] - fn dates_times_and_masks() { - let d = Format::Date("mm/dd/yyyy".into()); - assert_eq!(format_value(&d, "1/2/24"), "01/02/2024"); - assert_eq!(format_value(&Format::Date("mmmm d, yyyy".into()), "10/1/2026"), "October 1, 2026"); - assert_eq!(format_value(&Format::Date("dddd, mmm d, yyyy".into()), "10/1/2026"), "Thursday, Oct 1, 2026"); - assert_eq!(format_value(&Format::Date("dd-mmm-yy".into()), "5-Mar-21"), "05-Mar-21", "month names are read"); - assert!(keystroke(&d, "Due", "2/30/2024").is_err(), "no 30 February"); - assert!(keystroke(&d, "Due", "13/1/2024").is_err()); - // Literal digits in the format (a leave form fixing the year as "dd.mm.26"). - let fixed = Format::Date("dd.mm.26".into()); - assert_eq!(keystroke(&fixed, "Von", "15.05.26"), Ok("15.05.26".into())); - assert_eq!(format_value(&fixed, "15.05.26"), "15.05.26"); - assert_eq!(format_value(&fixed, "3.1"), "03.01.26", "the literal may be left out"); - assert!(keystroke(&fixed, "Von", "15.05.27").is_err(), "a different number there is not the literal"); - assert!(keystroke(&fixed, "Von", "32.05.26").is_err()); - assert_eq!(format_value(&Format::Time("h:MM tt".into()), "14:05"), "2:05 pm"); - assert_eq!(format_value(&Format::Time("HH:MM".into()), "2:05 pm"), "14:05"); - assert_eq!(format_value(&Format::Special(2), "5551234567"), "(555) 123-4567"); - assert_eq!(format_value(&Format::Special(3), "123-45-6789"), "123-45-6789"); - assert!(keystroke(&Format::Special(0), "Zip", "1234").is_err()); - assert_eq!(format_value(&Format::Mask("AA-9999".into()), "ab1234"), "ab-1234"); - assert!(keystroke(&Format::Mask("AA-9999".into()), "Code", "a11234").is_err()); - } - - #[test] - fn validation_and_calculation() { - let r = Validate::Range { min: Some(0.0), max: Some(100.0) }; - assert!(validate(&r, "50").is_ok() && validate(&r, "").is_ok()); - assert_eq!(validate(&r, "120"), Err("Invalid value: must be greater than or equal to 0 and less than or equal to 100.".into())); - let vals = |name: &str| -> Vec { - match name { - "a" => vec!["2".into()], - "b" => vec!["$3.50".into()], - "row" => vec!["1".into(), "2".into(), "3".into()], - "Unit Price" => vec!["4".into()], - _ => vec![], - } - }; - let simple = |op, f: &[&str]| calculate(&Calculate::Simple { op, fields: f.iter().map(|s| s.to_string()).collect() }, &vals); - assert_eq!(simple(CalcOp::Sum, &["a", "b"]), Some("5.5".into())); - assert_eq!(simple(CalcOp::Product, &["a", "b"]), Some("7".into())); - assert_eq!(simple(CalcOp::Average, &["row"]), Some("2".into()), "groups count every child"); - assert_eq!(simple(CalcOp::Maximum, &["a", "row"]), Some("3".into())); - assert_eq!(simple(CalcOp::Sum, &["missing"]), Some("0".into())); - assert_eq!(calculate(&Calculate::Notation("(a + b) * 2 - Unit\\ Price / 4".into()), &vals), Some("10".into())); - assert_eq!(calculate(&Calculate::Notation("a +".into()), &vals), None); - } -} +mod tests; // ── push buttons ──────────────────────────────────────────────────────────────────────────── diff --git a/crates/forms/src/af/calc.rs b/crates/forms/src/af/calc.rs new file mode 100644 index 0000000000..d29956cbc2 --- /dev/null +++ b/crates/forms/src/af/calc.rs @@ -0,0 +1,556 @@ +use super::format::{js_number, make_number}; +use super::script::{MAX_NESTING, Parser, js_str}; +use super::*; + +// ── renaming fields in calculations ───────────────────────────────────────────────────────── + +/// A JavaScript token, as far as finding the field names a calculation reads needs. +#[derive(Clone, Debug, PartialEq)] +pub(super) enum Js { + Ident, + /// A string literal, its escapes undone. + Str(String), + Punct(char), + /// A number, a regular expression or a template literal. + Other, +} + +/// A [`Js`] token with its byte range in the script. +type Token = (Range, Js); + +/// Words after which `/` starts a regular expression instead of dividing. +const BEFORE_REGEX: [&str; 13] = ["return", "typeof", "case", "do", "else", "in", "of", "new", "delete", "void", "throw", "instanceof", "yield"]; + +/// Words whose condition, in parentheses, comes before a statement, so a `/` after it starts a +/// regular expression. The `while` that ends a `do` loop counts too: the loop ends at its `)`, so +/// a `/` after it starts the next statement, and can't divide. +const BEFORE_CONDITION: [&str; 4] = ["if", "while", "for", "with"]; + +/// Whether a line ends at `s[j]`: JavaScript ends one at `\n`, `\r`, U+2028 and U+2029. +fn line_ends_at(s: &[u8], j: usize) -> bool { + matches!(s.get(j..), Some([b'\n' | b'\r', ..] | [0xE2, 0x80, 0xA8 | 0xA9, ..])) +} + +/// JavaScript's tokens with their byte ranges, and apart from them the text of each comment +/// (inside its `//`, or its `/*` and `*/`): enough of the language to tell code from strings, +/// comments and regular expressions. The tokens stop where a string, comment or template literal +/// never ends; a comment that never ends runs to the end of the script. +pub(super) fn js_tokens(js: &str) -> (Vec, Vec>) { + let s = js.as_bytes(); + // Whether `s[j]` is part of a word. Bytes past ASCII are parts of identifiers, except + // U+2028 and U+2029, which end a line: a range never ends inside a character. + let word = |j: usize| s.get(j).is_some_and(|&b| b.is_ascii_alphanumeric() || b == b'_' || b == b'$' || !b.is_ascii()) && !line_ends_at(s, j); + let mut out: Vec = Vec::new(); + let mut comments = Vec::new(); + let mut i = 0; + // Where a regular expression that never ended stopped being read: every `/` before it + // divides, so a line of them is read once, not once for each. + let mut divides_until = 0; + // For each `(` still open, whether it opened the condition of `if`, `while`, `for` or + // `with`; and whether the last token is the `)` that closed one. + let mut conditions: Vec = Vec::new(); + let mut after_condition = false; + 'scan: while let Some(&c) = s.get(i) { + let start = i; + let token = match c { + _ if c.is_ascii_whitespace() => { + i += 1; + continue; + } + // U+2028 or U+2029 between tokens ends a line, and is skipped whole: read a byte at a + // time, the rest of it would be read from inside the character. + 0xE2 if line_ends_at(s, i) => { + i += 3; + continue; + } + b'/' if s.get(i + 1) == Some(&b'/') => { + i = (i + 2..s.len()).find(|&j| line_ends_at(s, j)).unwrap_or(s.len()); + comments.push(start + 2..i); + continue; + } + b'/' if s.get(i + 1) == Some(&b'*') => match js.get(i + 2..).and_then(|rest| rest.find("*/")) { + Some(n) => { + comments.push(i + 2..i + 2 + n); + i += n + 4; + continue; + } + None => { + comments.push(i + 2..s.len()); + break 'scan; + } + }, + b'"' | b'\'' => { + let mut p = Parser { s, i, depth: 0 }; + let Some(text) = p.string() else { break 'scan }; + i = p.i; + Js::Str(text) + } + b'`' => { + let mut j = i + 1; + loop { + match s.get(j) { + None => break 'scan, + Some(b'\\') => j += 2, + Some(b'`') => break, + Some(_) => j += 1, + } + } + i = j + 1; + Js::Other + } + b'/' if i >= divides_until && regex_may_follow(js, &out, after_condition) => { + // A regular expression ends at a `/` outside a class; a line break first means it + // was a division after all. + let (mut j, mut class) = (i + 1, false); + let end = loop { + match s.get(j) { + None => break None, + Some(_) if line_ends_at(s, j) => break None, + Some(b'\\') => j += 2, + Some(b'/') if !class => break Some(j), + Some(b) => { + class = match b { + b'[' => true, + b']' => false, + _ => class, + }; + j += 1; + } + } + }; + match end { + Some(e) => { + i = e + 1; + while word(i) { + i += 1; + } + Js::Other + } + None => { + divides_until = j; + i += 1; + Js::Punct('/') + } + } + } + _ if c.is_ascii_digit() => { + while word(i) || s.get(i) == Some(&b'.') { + i += 1; + } + Js::Other + } + _ if word(i) => { + while word(i) { + i += 1; + } + Js::Ident + } + _ => { + i += 1; + Js::Punct(char::from(c)) + } + }; + after_condition = match token { + Js::Punct('(') => { + conditions.push(opens_condition(js, &out)); + false + } + Js::Punct(')') => conditions.pop().unwrap_or(false), + _ => false, + }; + out.push((start..i, token)); + } + (out, comments) +} + +/// Whether a `/` after `tokens` starts a regular expression: after an operator, an opening +/// bracket, a keyword such as `return` or the condition of `if`, `while`, `for` or `with`, not +/// after a value. `after_condition` says the last token is the `)` that ends such a condition. +fn regex_may_follow(js: &str, tokens: &[Token], after_condition: bool) -> bool { + match tokens.last() { + None => true, + Some((range, Js::Ident)) => js.get(range.clone()).is_some_and(|w| BEFORE_REGEX.contains(&w)), + Some((_, Js::Punct(')'))) => after_condition, + Some((_, Js::Punct(c))) => !matches!(c, ']' | '}'), + Some((_, Js::Str(_) | Js::Other)) => false, + } +} + +/// Whether a `(` after `tokens` opens the condition of `if`, `while`, `for` or `with`, and not +/// the arguments of a method that has one of those names (`o.if(x)`). +fn opens_condition(js: &str, tokens: &[Token]) -> bool { + let mut back = tokens.iter().rev(); + let Some((range, Js::Ident)) = back.next() else { return false }; + js.get(range.clone()).is_some_and(|w| BEFORE_CONDITION.contains(&w)) && !matches!(back.next(), Some((_, Js::Punct('.')))) +} + +/// Where a Calculate script keeps its simplified field notation: between `BVCALC` and the +/// `EVCALC` after it, in the first of `comments` that holds both, as Acrobat writes it +/// (`/** BVCALC a + b EVCALC **/`). A string or code that spells the markers is not the notation. +pub(super) fn notation_range(js: &str, comments: &[Range]) -> Option> { + comments.iter().find_map(|comment| { + let text = js.get(comment.clone())?; + let a = text.find("BVCALC")? + 6; + let b = a + text.get(a..)?.find("EVCALC")?; + Some(comment.start + a..comment.start + b) + }) +} + +/// A place where a Calculate script names a field. +#[derive(Clone, Debug, PartialEq)] +enum Site { + /// A string literal holding one name: `getField("a")`, or an item of an + /// `AFSimple_Calculate` list. + Literal(String), + /// An `AFSimple_Calculate` list written as one string of comma-separated names. + Commas(String), + /// A name in the simplified field notation of a `BVCALC` comment. + Notation(String), + /// A field read by a name built at run time: a `getField` whose argument is more than one + /// string literal (`getField("row" + i)`), or an `AFSimple_Calculate` list that isn't + /// written out. Which field it is isn't known. + Built, +} + +impl Site { + fn names(&self) -> Vec<&str> { + match self { + Site::Literal(name) | Site::Notation(name) => vec![name.as_str()], + Site::Commas(list) => list.split(',').map(str::trim).filter(|n| !n.is_empty()).collect(), + Site::Built => Vec::new(), + } + } + + /// The site written again with field `old` named `new`, or `None` when it doesn't name `old`. + fn renamed(&self, old: &str, new: &str) -> Option { + match self { + Site::Literal(name) => (name == old).then(|| js_str(new)), + Site::Notation(name) => (name == old).then(|| notation_name(new)), + Site::Commas(list) => { + let parts: Vec<&str> = list.split(',').collect(); + parts.iter().any(|p| p.trim() == old).then(|| { + // The spaces around each name stay as they were. + let parts: Vec = parts.iter().map(|p| if p.trim() == old { p.replacen(old, new, 1) } else { (*p).to_owned() }).collect(); + js_str(&parts.join(",")) + }) + } + Site::Built => None, + } + } +} + +/// Where a Calculate script names fields, in order: the simplified field notation in its comment +/// (see [`notation_range`]; [`parse_calculate`] reads it there too), the field list of +/// `AFSimple_Calculate`, and every `getField` whose whole argument is one string literal. A name +/// built at run time (`getField("row" + i)`) is a [`Site::Built`], at its `getField` or +/// `AFSimple_Calculate`. +fn calculation_sites(js: &str) -> Vec<(Range, Site)> { + let mut sites = Vec::new(); + let (tokens, comments) = js_tokens(js); + if let Some(at) = notation_range(js, &comments) + && let Some(expr) = js.get(at.clone()) + { + for (range, token) in notation_tokens(expr) { + if let Notation::Name(name) = token { + sites.push((at.start + range.start..at.start + range.end, Site::Notation(name))); + } + } + } + let ident = |k: usize, word: &str| tokens.get(k).is_some_and(|(range, t)| *t == Js::Ident && js.get(range.clone()) == Some(word)); + let punct = |k: usize, c: char| tokens.get(k).is_some_and(|(_, t)| *t == Js::Punct(c)); + let string = |k: usize| match tokens.get(k) { + Some((range, Js::Str(text))) => Some((range.clone(), text.clone())), + _ => None, + }; + let built = |k: usize| tokens.get(k).map(|(range, _)| (range.clone(), Site::Built)); + for k in 0..tokens.len() { + if ident(k, "getField") && punct(k + 1, '(') { + match string(k + 2) { + Some((range, name)) if punct(k + 3, ')') => sites.push((range, Site::Literal(name))), + _ => sites.extend(built(k)), + } + } + if !(ident(k, "AFSimple_Calculate") && punct(k + 1, '(')) { + continue; + } + if !(string(k + 2).is_some() && punct(k + 3, ',')) { + sites.extend(built(k)); + continue; + } + // One string of comma-separated names, when it is the whole list (not `"row" + suffix`). + // An argument after it leaves the list as written, as after an array: its names are read, + // and what follows is a name built at run time. + if let Some((range, list)) = string(k + 4) { + if punct(k + 5, ')') || punct(k + 5, ',') { + sites.push((range, Site::Commas(list))); + } + if !punct(k + 5, ')') { + sites.extend(built(k)); + } + continue; + } + // `new Array("a", "b")` or `["a", "b"]`: each item that is one string, and the list has + // to end the call. An item that is more than a string (`"row" + i`), or anything after + // the list (`.concat(more)`), is a name built at run time. Nothing else is read, so no + // token is read for two calls. + let (first, close) = if punct(k + 4, '[') { + (k + 5, ']') + } else if ident(k + 4, "new") && ident(k + 5, "Array") && punct(k + 6, '(') { + (k + 7, ')') + } else { + sites.extend(built(k)); + continue; + }; + let mut j = first; + loop { + match tokens.get(j) { + // An item ends at a comma or the list's end, or where a script cut short ends. + Some((range, Js::Str(name))) if punct(j + 1, ',') || punct(j + 1, close) || tokens.get(j + 1).is_none() => { + sites.push((range.clone(), Site::Literal(name.clone()))); + j += 1; + } + Some((_, Js::Punct(','))) => j += 1, + Some((_, Js::Punct(c))) if *c == close => { + if !punct(j + 1, ')') { + sites.extend(built(k)); + } + break; + } + _ => { + sites.extend(built(k)); + break; + } + } + } + } + sites.sort_by_key(|(range, _)| range.start); + sites +} + +/// A Calculate script with field `old` named `new` everywhere it reads it (see +/// [`calculation_sites`]), the rest kept as written; `None` when it doesn't read `old`. Also +/// `None` when the new name would read as more than a name there (one holding `EVCALC` would end +/// the notation early), so a script is never left meaning something else. +pub fn rename_in_calculation(js: &str, old: &str, new: &str) -> Option { + let sites = calculation_sites(js); + let edits: Vec<(Range, String)> = sites.iter().filter_map(|(range, site)| Some((range.clone(), site.renamed(old, new)?))).collect(); + if edits.is_empty() { + return None; + } + let out = splice(js, edits); + let expected: Vec<&str> = sites.iter().flat_map(|(_, site)| site.names()).map(|n| if n == old { new } else { n }).collect(); + let written = calculation_sites(&out); + let got: Vec<&str> = written.iter().flat_map(|(_, site)| site.names()).collect(); + (got == expected).then_some(out) +} + +/// The fields a Calculate script reads by name (see [`calculation_sites`]), each once, in the +/// order it first names them. +pub fn calculation_reads(js: &str) -> Vec { + let sites = calculation_sites(js); + unique(sites.iter().flat_map(|(_, site)| site.names()).map(str::to_owned)) +} + +/// Whether a Calculate script also reads fields by names it builds at run time +/// (`getField("row" + i)`), which [`calculation_reads`] can't list. +pub fn reads_built_names(js: &str) -> bool { + calculation_sites(js).iter().any(|(_, site)| *site == Site::Built) +} + +impl Calculate { + /// The fields it reads, each once, in the order it first names them. A name also stands for + /// the fields under it (`row` for `row.0` and `row.1`), as [`calculate`] reads them. + pub fn reads(&self) -> Vec { + match self { + Calculate::None => Vec::new(), + Calculate::Simple { fields, .. } => unique(fields.iter().cloned()), + Calculate::Notation(expr) => unique(notation_tokens(expr).into_iter().filter_map(|(_, token)| match token { + Notation::Name(name) => Some(name), + _ => None, + })), + } + } +} + +/// `names` without repeats, in order. +pub(super) fn unique(names: impl IntoIterator) -> Vec { + let mut seen = std::collections::HashSet::new(); + names.into_iter().filter(|n| seen.insert(n.clone())).collect() +} + +/// A token of simplified field notation. +#[derive(Clone, Debug, PartialEq)] +pub(super) enum Notation { + /// A number, as written. + Number, + /// `+ - * / ( )`. + Op(char), + /// A field name, its `\` escapes undone. + Name(String), +} + +/// Simplified field notation's tokens, each with its byte range in `src`. Whitespace separates +/// them, and `\` escapes a space or an operator inside a name, as Acrobat writes them. +pub(super) fn notation_tokens(src: &str) -> Vec<(Range, Notation)> { + let breaks = |c: char| c.is_whitespace() || "+-*/()".contains(c); + let mut out = Vec::new(); + let mut cs = src.char_indices().peekable(); + while let Some(&(start, c)) = cs.peek() { + let mut end = start + c.len_utf8(); + if c.is_whitespace() { + cs.next(); + } else if breaks(c) { + cs.next(); + out.push((start..end, Notation::Op(c))); + } else if c.is_ascii_digit() || c == '.' { + while let Some((i, d)) = cs.next_if(|(_, d)| d.is_ascii_digit() || *d == '.') { + end = i + d.len_utf8(); + } + out.push((start..end, Notation::Number)); + } else { + let mut name = String::new(); + while let Some((i, n)) = cs.next_if(|(_, n)| !breaks(*n)) { + end = i + n.len_utf8(); + if n == '\\' + && let Some((j, next)) = cs.next() + { + end = j + next.len_utf8(); + name.push(next); + } else { + name.push(n); + } + } + out.push((start..end, Notation::Name(name))); + } + } + out +} + +/// A field name as simplified field notation writes it: `\` before a space, an operator or a +/// `\`, and before a first character that would start a number. +pub(super) fn notation_name(name: &str) -> String { + let mut out = String::new(); + for (i, c) in name.chars().enumerate() { + if c.is_whitespace() || "+-*/()\\".contains(c) || (i == 0 && (c.is_ascii_digit() || c == '.')) { + out.push('\\'); + } + out.push(c); + } + out +} + +/// `src` with each range replaced. The ranges come in order and don't overlap; one that does +/// is left out rather than written over another. +fn splice(src: &str, edits: impl IntoIterator, String)>) -> String { + let mut out = String::with_capacity(src.len()); + let mut done = 0; + for (range, with) in edits { + let Some(kept) = src.get(done..range.start).filter(|_| range.start <= range.end) else { continue }; + out.push_str(kept); + out.push_str(&with); + done = range.end; + } + out.push_str(src.get(done..).unwrap_or_default()); + out +} + +/// Simplified field notation as the JavaScript Acrobat writes after its `BVCALC` comment: +/// each field name becomes `AFMakeNumber(this.getField("name").value)`, the rest stays. +pub fn notation_js(expr: &str) -> String { + let names = notation_tokens(expr).into_iter().filter_map(|(range, t)| match t { + Notation::Name(name) => Some((range, format!("AFMakeNumber(this.getField({}).value)", js_str(&name)))), + _ => None, + }); + splice(expr, names) +} + +/// Evaluate simplified field notation: + - * / and parentheses over numbers and field names. +pub(super) fn eval_notation(src: &str, value_of: &dyn Fn(&str) -> f64) -> Option { + #[derive(Debug, Clone, PartialEq)] + enum T { + N(f64), + Op(char), + } + let toks = notation_tokens(src) + .into_iter() + .map(|(range, t)| match t { + Notation::Number => src.get(range)?.parse().ok().map(T::N), + Notation::Op(c) => Some(T::Op(c)), + Notation::Name(name) => Some(T::N(value_of(&name))), + }) + .collect::>>()?; + // Recursive descent, `d` levels deep (at most MAX_NESTING). + fn expr(t: &[T], i: &mut usize, d: usize) -> Option { + let mut v = term(t, i, d)?; + while let Some(T::Op(o @ ('+' | '-'))) = t.get(*i) { + *i += 1; + let r = term(t, i, d)?; + v = if *o == '+' { v + r } else { v - r }; + } + Some(v) + } + fn term(t: &[T], i: &mut usize, d: usize) -> Option { + let mut v = factor(t, i, d)?; + while let Some(T::Op(o @ ('*' | '/'))) = t.get(*i) { + *i += 1; + let r = factor(t, i, d)?; + v = if *o == '*' { v * r } else { v / r }; + } + Some(v) + } + fn factor(t: &[T], i: &mut usize, d: usize) -> Option { + if d > MAX_NESTING { + return None; + } + match t.get(*i)? { + T::N(n) => { + *i += 1; + Some(*n) + } + T::Op('-') => { + *i += 1; + factor(t, i, d + 1).map(|v| -v) + } + T::Op('(') => { + *i += 1; + let v = expr(t, i, d + 1)?; + (t.get(*i) == Some(&T::Op(')'))).then(|| *i += 1)?; + Some(v) + } + _ => None, + } + } + let mut i = 0; + let v = expr(&toks, &mut i, 0)?; + (i == toks.len() && v.is_finite()).then_some(v) +} + +/// The Calculate event: the new value, given the current values of the other fields +/// (`values(name)` returns every field named `name` or below it, for groups). +pub fn calculate(c: &Calculate, values: &dyn Fn(&str) -> Vec) -> Option { + let num = |s: &String| make_number(s).unwrap_or(0.0); + match c { + Calculate::None => None, + Calculate::Simple { op, fields } => { + let xs: Vec = fields.iter().flat_map(|f| values(f)).map(|v| num(&v)).collect(); + let v = match op { + CalcOp::Sum => xs.iter().sum(), + CalcOp::Product => xs.iter().product(), + CalcOp::Average => { + if xs.is_empty() { + 0.0 + } else { + xs.iter().sum::() / xs.len() as f64 + } + } + CalcOp::Minimum => xs.iter().copied().fold(f64::INFINITY, f64::min), + CalcOp::Maximum => xs.iter().copied().fold(f64::NEG_INFINITY, f64::max), + }; + Some(if v.is_finite() { js_number(v) } else { "0".into() }) + } + Calculate::Notation(expr) => { + let v = eval_notation(expr, &|name| values(name).first().map(num).unwrap_or(0.0))?; + Some(js_number(v)) + } + } +} diff --git a/crates/forms/src/af/format.rs b/crates/forms/src/af/format.rs new file mode 100644 index 0000000000..df4720bec7 --- /dev/null +++ b/crates/forms/src/af/format.rs @@ -0,0 +1,533 @@ +use super::*; + +/// Acrobat's AFDate_Format(n) / AFTime_Format(n) presets. +pub const DATE_PRESETS: [&str; 14] = [ + "m/d", + "m/d/yy", + "mm/dd/yy", + "mm/yy", + "d-mmm", + "d-mmm-yy", + "dd-mmm-yy", + "yy-mm-dd", + "mmm-yy", + "mmmm-yy", + "mmm d, yyyy", + "mmmm d, yyyy", + "m/d/yy h:MM tt", + "m/d/yy HH:MM", +]; + +pub const TIME_PRESETS: [&str; 4] = ["HH:MM", "h:MM tt", "HH:MM:ss", "h:MM:ss tt"]; + +// ── running them ──────────────────────────────────────────────────────────────────────────── + +/// AFMakeNumber: a number from typed text (currency symbols, group separators, spaces and a +/// trailing % ignored; a comma decimal separator accepted). +pub fn make_number(s: &str) -> Option { + let t: String = s.trim().chars().filter(|c| !c.is_whitespace() && !matches!(c, '$' | '€' | '£' | '¥' | '\'' | '%')).collect(); + if t.is_empty() { + return None; + } + let neg = t.starts_with('(') && t.ends_with(')'); + let t = t.trim_start_matches('(').trim_end_matches(')'); + // "1.234,56" or "1234,56": comma is the decimal separator when it comes last. + let normalized = match (t.rfind(','), t.rfind('.')) { + (Some(c), Some(d)) if c > d => t.replace('.', "").replace(',', "."), + (Some(c), None) if t.len() - c - 1 != 3 => t.replace(',', "."), + _ => t.replace(',', ""), + }; + let v: f64 = normalized.parse().ok()?; + v.is_finite().then_some(if neg { -v } else { v }) +} + +/// A number as JavaScript prints it (integers without a fraction). +pub fn js_number(v: f64) -> String { + if v.fract() == 0.0 && v.abs() < 1e15 { format!("{}", v as i64) } else { format!("{v}") } +} + +fn group(int: &str, sep: char) -> String { + let mut out = String::new(); + for (i, c) in int.chars().enumerate() { + if i > 0 && (int.len() - i).is_multiple_of(3) { + out.push(sep); + } + out.push(c); + } + out +} + +/// A number with `decimals` places in separator style `sep` (0 "1,234.56", 1 "1234.56", +/// 2 "1.234,56", 3 "1234,56", 4 "1'234.56"). +fn number_text(v: f64, decimals: u8, sep: u8) -> String { + // Round half away from zero, as JavaScript's toFixed does for these values. + let p = 10f64.powi(i32::from(decimals)); + let scaled = v.abs() * p; + // Past 2^53 every value is already whole, and scaling could overflow to infinity. + let r = if scaled.is_finite() { scaled.round() / p } else { v.abs() }; + let s = format!("{:.*}", decimals as usize, r); + let (int, frac) = s.split_once('.').map_or((s.as_str(), ""), |(a, b)| (a, b)); + let (g, d) = match sep { + 1 => (None, '.'), + 2 => (Some('.'), ','), + 3 => (None, ','), + 4 => (Some('\''), '.'), + _ => (Some(','), '.'), + }; + let mut out = match g { + Some(c) => group(int, c), + None => int.to_string(), + }; + if !frac.is_empty() { + out.push(d); + out.push_str(frac); + } + out +} + +const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; + +const DAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; + +/// A date and time read from text. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct DateTime { + pub y: i32, + pub m: u32, + pub d: u32, + pub hh: u32, + pub mm: u32, + pub ss: u32, +} + +fn days_in(y: i32, m: u32) -> u32 { + match m { + 2 if (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 => 29, + 2 => 28, + 4 | 6 | 9 | 11 => 30, + _ => 31, + } +} + +fn weekday(y: i32, m: u32, d: u32) -> usize { + // Sakamoto's method, in i64 so no year or day can overflow it. + const T: [i64; 12] = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4]; + let y = i64::from(y) - i64::from(m < 3); + let t = T.get((m as usize).wrapping_sub(1)).copied().unwrap_or(0); + (y + y / 4 - y / 100 + y / 400 + t + i64::from(d)).rem_euclid(7) as usize +} + +/// One field of a date format, in the order the format has them. +struct Part { + /// `'y'`, `'m'`, `'d'`, `'H'`, `'M'` or `'s'`; a run of literal digits (the fixed year in + /// "dd.mm.26") is `'#'`. + kind: char, + /// The value of a `'#'` run; 0 for the others. + literal: u32, + /// The digits the field takes when typed run together with the next one: 4 for "yyyy", 2 for + /// "yy", "mm", "dd", "HH", "MM" and "ss". 0 when the width varies ("m", "d") or the field is + /// a name ("mmm"). + width: usize, + /// The next field follows with nothing between them ("yyyymmdd"), so one run of typed digits + /// can hold both. + joined: bool, +} + +/// The fields of a format (year, month, day, hours, minutes, seconds and literal digits), in +/// order. +fn order(fmt: &str) -> Vec { + let mut out: Vec = Vec::new(); + // Weekday names (ddd, dddd) are shown, not typed: leave them out. + let mut cleaned = fmt.replace("dddd", " "); + cleaned = cleaned.replace("ddd", " "); + let mut prev = ' '; + // The letter of the last field's current run and its length so far. + let mut run: Option<(char, usize)> = None; + let fixed = |k: char, n: usize| match (k, n) { + ('y', 2 | 4) | ('m' | 'd' | 'H' | 'M' | 's', 2) => n, + _ => 0, + }; + for c in cleaned.chars() { + let continues = run.is_some_and(|(r, _)| r == c); + if let Some((r, n)) = run + && !continues + { + // The run ended; it was the last field's width. + if let Some(last) = out.last_mut().filter(|p| p.kind != '#') { + last.width = fixed(if r == 'h' { 'H' } else { r }, n); + } + run = None; + } + if let Some(digit) = c.to_digit(10) { + match out.last_mut() { + Some(p) if p.kind == '#' && prev.is_ascii_digit() => p.literal = p.literal.saturating_mul(10).saturating_add(digit), + _ => out.push(Part { kind: '#', literal: digit, width: 0, joined: false }), + } + prev = c; + continue; + } + let k = match c { + 'y' => 'y', + 'm' => 'm', + 'd' => 'd', + 'H' | 'h' => 'H', + 'M' => 'M', + 's' => 's', + _ => { + prev = c; + continue; + } + }; + if continues { + run = run.map(|(r, n)| (r, n.saturating_add(1))); + } else { + // A field starting right where the last one's letters stopped is joined to it. + let adjacent = matches!(prev, 'y' | 'm' | 'd' | 'H' | 'h' | 'M' | 's'); + if out.last().map(|p| p.kind) != Some(k) { + if let Some(last) = out.last_mut().filter(|p| p.kind != '#') { + last.joined = adjacent; + } + out.push(Part { kind: k, literal: 0, width: 0, joined: false }); + } + run = Some((c, 1)); + } + prev = c; + } + if let (Some((r, n)), Some(last)) = (run, out.last_mut().filter(|p| p.kind != '#')) { + last.width = fixed(if r == 'h' { 'H' } else { r }, n); + } + out +} + +/// Parse typed text against a date/time format (AFDate_KeystrokeEx's leniency: any +/// separators, month names, two-digit years). `None` when it isn't a valid date. +pub fn parse_date(text: &str, fmt: &str) -> Option { + let t = text.trim(); + if t.is_empty() { + return None; + } + let lower = t.to_lowercase(); + // A bare trailing "p" ("5:30p") only counts when it follows a digit, not a word like "Sep". + let bare_p = lower.strip_suffix('p').is_some_and(|rest| rest.trim_end().ends_with(|c: char| c.is_ascii_digit())); + let pm = lower.contains("pm") || bare_p; + let bare_a = lower.strip_suffix('a').is_some_and(|rest| rest.trim_end().ends_with(|c: char| c.is_ascii_digit())); + let am = lower.contains("am") || bare_a; + // Tokens: runs of digits (kept as text, so a run can be split between fields typed without + // separators) and month names. + let mut nums: VecDeque = VecDeque::new(); + let mut month_name: Option = None; + let mut cur = String::new(); + let flush = |cur: &mut String, nums: &mut VecDeque, month: &mut Option| { + if cur.is_empty() { + return; + } + if cur.starts_with(|c: char| c.is_ascii_digit()) { + nums.push_back(std::mem::take(cur)); + } else if cur.len() >= 3 + && let Some(i) = MONTHS.iter().position(|m| m.to_lowercase().starts_with(&cur.to_lowercase())) + { + *month = Some(i as u32 + 1); + } + cur.clear(); + }; + for c in t.chars() { + // Digits and letters form separate tokens ("5Mar" is 5 and Mar). + let same_kind = cur.is_empty() || cur.chars().next().is_some_and(|x| x.is_ascii_digit()) == c.is_ascii_digit(); + if c.is_alphanumeric() && same_kind { + cur.push(c); + } else { + flush(&mut cur, &mut nums, &mut month_name); + if c.is_alphanumeric() { + cur.push(c); + } + } + } + flush(&mut cur, &mut nums, &mut month_name); + let mut dt = DateTime { y: 0, m: 1, d: 1, hh: 0, mm: 0, ss: 0 }; + // The next number for a field. A field joined to the next one in the format ("yyyymmdd") + // takes only its own width from a longer run of digits and leaves the rest for the next. + // A run typed on its own is split only when fewer runs were typed than the fields of its kind + // still to fill (`need`, this one included: date fields for a date field, time fields for a + // time field, since times may be left out), and only when its rest can fill the next field + // (`next_width`): with separators each run is one field's number, so "2026-10-18" keeps its + // four-digit year in a yymmdd or yymmddHHMM field, and "2026 10 018" is the 18th. What is left + // of a split run (`tail`: the front of `nums` is one) goes on being split along the fields + // written side by side, and must fill its last field exactly, so "202610018" leaves "018" for + // dd and is refused. `None` when there is no number left, it doesn't fit a u32, or such a + // rest has the wrong width. + let next_number = |nums: &mut VecDeque, tail: &mut bool, part: &Part, need: usize, next_width: usize| -> Option { + let run = nums.pop_front()?; + let was_tail = std::mem::replace(tail, false); + let short = nums.len() < need.saturating_sub(1); + let digits = match (run.get(..part.width), run.get(part.width..)) { + (Some(head), Some(rest)) if part.joined && part.width > 0 && !rest.is_empty() && (was_tail || (short && rest.len() >= next_width)) => { + nums.push_front(rest.to_string()); + *tail = true; + head + } + _ if was_tail && part.width > 0 && run.len() != part.width => return None, + _ => run.as_str(), + }; + digits.parse::().ok() + }; + let parts = order(fmt); + let takes_number = |p: &Part| matches!(p.kind, 'y' | 'd' | 'H' | 'M' | 's') || (p.kind == 'm' && month_name.is_none()); + let is_time = |p: &Part| matches!(p.kind, 'H' | 'M' | 's'); + // The fields of each kind still to fill: [date, time]. + let mut wanted = [parts.iter().filter(|p| takes_number(p) && !is_time(p)).count(), parts.iter().filter(|p| is_time(p)).count()]; + let mut tail = false; + let mut have_y = false; + for (i, part) in parts.iter().enumerate() { + let kind = usize::from(is_time(part)); + let need = wanted.get(kind).copied().unwrap_or(0); + let next_width = parts.get(i + 1).map_or(0, |p| p.width); + if takes_number(part) + && let Some(left) = wanted.get_mut(kind) + { + *left = left.saturating_sub(1); + } + let take = |nums: &mut VecDeque, tail: &mut bool| next_number(nums, tail, part, need, next_width); + match part.kind { + // Literal digits in the format may be typed or left out ("15.05.26" or "15.05" + // for "dd.mm.26"); a different number there is left over and rejected below. + '#' if nums.front().and_then(|n| n.parse::().ok()) == Some(part.literal) => { + nums.pop_front(); + tail = false; + } + 'm' => { + dt.m = match month_name { + Some(m) => m, + None => take(&mut nums, &mut tail)?, + } + } + 'd' => dt.d = take(&mut nums, &mut tail)?, + 'y' => { + let y = i32::try_from(take(&mut nums, &mut tail)?).ok()?; + dt.y = if y < 100 { if y < 50 { 2000 + y } else { 1900 + y } } else { y }; + have_y = true; + } + // Times may be left out; a number that is there must be readable. + 'H' => dt.hh = if nums.is_empty() { 0 } else { take(&mut nums, &mut tail)? }, + 'M' => dt.mm = if nums.is_empty() { 0 } else { take(&mut nums, &mut tail)? }, + 's' => dt.ss = if nums.is_empty() { 0 } else { take(&mut nums, &mut tail)? }, + _ => {} + } + } + if !nums.is_empty() { + return None; + } + if !have_y { + dt.y = 2000; + } + if pm && dt.hh < 12 { + dt.hh += 12; + } + if am && dt.hh == 12 { + dt.hh = 0; + } + let has_date = fmt.contains(['y', 'm', 'd']); + if has_date && (!(1..=12).contains(&dt.m) || dt.d == 0 || dt.d > days_in(dt.y, dt.m)) { + return None; + } + if dt.hh > 23 || dt.mm > 59 || dt.ss > 59 { + return None; + } + Some(dt) +} + +/// Format a date/time with Acrobat's date format letters. +pub fn format_date(dt: DateTime, fmt: &str) -> String { + let chars: Vec = fmt.chars().collect(); + let mut out = String::new(); + let mut i = 0; + while i < chars.len() { + let c = chars[i]; + let mut n = 1; + while i + n < chars.len() && chars[i + n] == c { + n += 1; + } + let h12 = if dt.hh.is_multiple_of(12) { 12 } else { dt.hh % 12 }; + match (c, n) { + ('y', 4..) => { + let _ = write!(out, "{:04}", dt.y); + } + ('y', _) => { + let _ = write!(out, "{:02}", dt.y.rem_euclid(100)); + } + ('m', 3..) => match MONTHS.get((dt.m as usize).wrapping_sub(1)) { + Some(name) if n >= 4 => out.push_str(name), + Some(name) => out.push_str(name.get(..3).unwrap_or(name)), + None => { + let _ = write!(out, "{}", dt.m); + } + }, + ('m', 2) => { + let _ = write!(out, "{:02}", dt.m); + } + ('m', _) => { + let _ = write!(out, "{}", dt.m); + } + ('d', 3..) => { + let name = DAYS.get(weekday(dt.y, dt.m, dt.d)).copied().unwrap_or_default(); + out.push_str(if n >= 4 { name } else { name.get(..3).unwrap_or(name) }); + } + ('d', 2) => { + let _ = write!(out, "{:02}", dt.d); + } + ('d', _) => { + let _ = write!(out, "{}", dt.d); + } + ('H', 2) => { + let _ = write!(out, "{:02}", dt.hh); + } + ('H', _) => { + let _ = write!(out, "{}", dt.hh); + } + ('h', 2) => { + let _ = write!(out, "{h12:02}"); + } + ('h', _) => { + let _ = write!(out, "{h12}"); + } + ('M', 2) => { + let _ = write!(out, "{:02}", dt.mm); + } + ('M', _) => { + let _ = write!(out, "{}", dt.mm); + } + ('s', 2) => { + let _ = write!(out, "{:02}", dt.ss); + } + ('s', _) => { + let _ = write!(out, "{}", dt.ss); + } + ('t', 2) => out.push_str(if dt.hh < 12 { "am" } else { "pm" }), + ('t', _) => out.push(if dt.hh < 12 { 'a' } else { 'p' }), + (c, n) => out.extend(std::iter::repeat_n(c, n)), + } + i += n; + } + out +} + +const SPECIAL_MASKS: [&str; 4] = ["99999", "99999-9999", "(999) 999-9999", "999-99-9999"]; + +fn mask_fits(mask: &str, text: &str) -> bool { + let t: Vec = text.chars().collect(); + let m: Vec = mask.chars().collect(); + t.len() == m.len() + && m.iter().zip(&t).all(|(mc, tc)| match mc { + '9' => tc.is_ascii_digit(), + 'A' => tc.is_alphabetic(), + 'O' => tc.is_alphanumeric(), + 'X' => true, + other => other == tc, + }) +} + +/// Fill a mask from the typed characters that fit its slots. +fn apply_mask(mask: &str, text: &str) -> Option { + let mut src = text.chars().filter(|c| c.is_alphanumeric()); + let mut out = String::new(); + for mc in mask.chars() { + match mc { + '9' | 'A' | 'O' | 'X' => { + let c = src.next()?; + let ok = match mc { + '9' => c.is_ascii_digit(), + 'A' => c.is_alphabetic(), + _ => true, + }; + if !ok { + return None; + } + out.push(c); + } + other => out.push(other), + } + } + src.next().is_none().then_some(out) +} + +/// The text shown for a value (the Format event). +pub fn format_value(f: &Format, value: &str) -> String { + if value.is_empty() { + return String::new(); + } + match f { + Format::None => value.to_string(), + Format::Number { decimals, sep, neg, currency, prepend } => { + let Some(v) = make_number(value) else { return value.to_string() }; + let body = number_text(v, *decimals, *sep); + let body = match (currency.is_empty(), *prepend) { + (true, _) => body, + (false, true) => format!("{currency}{body}"), + (false, false) => format!("{body}{currency}"), + }; + match (v < 0.0, neg) { + (false, _) => body, + // Styles 2 and 3 use parentheses (3 is also red, which the appearance can't + // know here); 0 and 1 a minus sign. + (true, 2 | 3) => format!("({body})"), + (true, _) => format!("-{body}"), + } + } + Format::Percent { decimals, sep } => match make_number(value) { + Some(v) => format!( + "{}%", + if v < 0.0 { format!("-{}", number_text(v * 100.0, *decimals, *sep)) } else { number_text(v * 100.0, *decimals, *sep) } + ), + None => value.to_string(), + }, + Format::Date(fmt) | Format::Time(fmt) => parse_date(value, fmt).map_or_else(|| value.to_string(), |dt| format_date(dt, fmt)), + Format::Special(n) => apply_mask(SPECIAL_MASKS[(*n).min(3) as usize], value).unwrap_or_else(|| value.to_string()), + Format::Mask(m) => apply_mask(m, value).unwrap_or_else(|| value.to_string()), + } +} + +/// The Keystroke event on commit: does the value fit the format? Returns the value to store +/// (numbers are normalised), or Acrobat's message. +pub fn keystroke(f: &Format, field: &str, value: &str) -> Result { + let bad = || format!("The value entered does not match the format of the field [ {field} ]"); + // Blank input clears a formatted field; without a format, whitespace is text like any other. + if *f != Format::None && value.trim().is_empty() { + return Ok(String::new()); + } + match f { + Format::None => Ok(value.to_string()), + Format::Number { .. } => make_number(value).map(js_number).ok_or_else(bad), + Format::Percent { .. } => { + // Typed "12.5%" or "12.5" both mean 0.125 once stored? Acrobat stores the number + // as typed and shows it ×100: keep the typed number. + make_number(value).map(js_number).ok_or_else(bad) + } + Format::Date(fmt) | Format::Time(fmt) => parse_date(value, fmt) + .map(|_| value.trim().to_string()) + .ok_or_else(|| format!("Invalid date/time: please ensure that the date/time exists. Field [ {field} ] should match format {fmt}")), + Format::Special(n) => { + let m = SPECIAL_MASKS[(*n).min(3) as usize]; + apply_mask(m, value).filter(|s| mask_fits(m, s)).map(|_| value.trim().to_string()).ok_or_else(bad) + } + Format::Mask(m) => apply_mask(m, value).map(|_| value.trim().to_string()).ok_or_else(bad), + } +} + +/// The Validate event: `Err` with Acrobat's message when the value is out of range. +pub fn validate(v: &Validate, value: &str) -> Result<(), String> { + let Validate::Range { min, max } = v else { return Ok(()) }; + if value.trim().is_empty() { + return Ok(()); + } + let Some(x) = make_number(value) else { return Ok(()) }; + let fmt = |n: f64| js_number(n); + let ok = min.is_none_or(|m| x >= m) && max.is_none_or(|m| x <= m); + if ok { + return Ok(()); + } + Err(match (min, max) { + (Some(a), Some(b)) => format!("Invalid value: must be greater than or equal to {} and less than or equal to {}.", fmt(*a), fmt(*b)), + (Some(a), None) => format!("Invalid value: must be greater than or equal to {}.", fmt(*a)), + (None, Some(b)) => format!("Invalid value: must be less than or equal to {}.", fmt(*b)), + (None, None) => String::new(), + }) +} diff --git a/crates/forms/src/af/script.rs b/crates/forms/src/af/script.rs new file mode 100644 index 0000000000..f15714c411 --- /dev/null +++ b/crates/forms/src/af/script.rs @@ -0,0 +1,363 @@ +use super::calc::{js_tokens, notation_js, notation_range, notation_tokens}; +use super::format::{DATE_PRESETS, TIME_PRESETS}; +use super::{CalcOp, Calculate, Format, Validate}; + +// ── parsing scripts ───────────────────────────────────────────────────────────────────────── + +#[derive(Clone, Debug, PartialEq)] +pub(super) enum Arg { + Num(f64), + Str(String), + Bool(bool), + List(Vec), +} + +impl Arg { + fn num(&self) -> Option { + match self { + Arg::Num(n) => Some(*n), + Arg::Bool(b) => Some(f64::from(u8::from(*b))), + Arg::Str(s) => s.trim().parse().ok(), + Arg::List(_) => None, + } + } + + pub(super) fn string(&self) -> Option { + match self { + Arg::Str(s) => Some(s.clone()), + Arg::Num(n) => Some(n.to_string()), + _ => None, + } + } + + fn truthy(&self) -> bool { + match self { + Arg::Bool(b) => *b, + Arg::Num(n) => *n != 0.0, + Arg::Str(s) => !s.is_empty(), + Arg::List(l) => !l.is_empty(), + } + } +} + +/// How deeply script arguments and field notation may nest; deeper input is rejected rather +/// than recursed into (a hostile file could otherwise overflow the stack). +pub(super) const MAX_NESTING: usize = 64; + +fn hex4(bytes: &[u8]) -> Option { + if bytes.len() != 4 { + return None; + } + bytes.iter().try_fold(0u16, |value, byte| { + let digit = match byte { + b'0'..=b'9' => byte - b'0', + b'a'..=b'f' => byte - b'a' + 10, + b'A'..=b'F' => byte - b'A' + 10, + _ => return None, + }; + value.checked_mul(16)?.checked_add(u16::from(digit)) + }) +} + +pub(super) struct Parser<'a> { + pub(super) s: &'a [u8], + pub(super) i: usize, + pub(super) depth: usize, +} + +impl Parser<'_> { + fn ws(&mut self) { + while self.i < self.s.len() && (self.s[self.i].is_ascii_whitespace()) { + self.i += 1; + } + } + + pub(super) fn string(&mut self) -> Option { + let q = self.s[self.i]; + self.i += 1; + let mut out = Vec::new(); + while self.i < self.s.len() { + let c = self.s[self.i]; + self.i += 1; + match c { + b'\\' if self.i < self.s.len() => { + let e = self.s[self.i]; + self.i += 1; + match e { + b'n' => out.push(b'\n'), + b'r' => out.push(b'\r'), + b't' => out.push(b'\t'), + b'b' => out.push(0x08), + b'f' => out.push(0x0C), + b'v' => out.push(0x0B), + b'0' => out.push(0), + b'x' => { + let digits = self.s.get(self.i..self.i.checked_add(2)?)?; + let code = hex4(&[b'0', b'0', digits[0], digits[1]])?; + self.i += 2; + let ch = char::from_u32(u32::from(code)).unwrap_or('\u{FFFD}'); + let mut encoded = [0u8; 4]; + out.extend_from_slice(ch.encode_utf8(&mut encoded).as_bytes()); + } + b'u' => { + let code = hex4(self.s.get(self.i..self.i.checked_add(4)?)?)?; + self.i += 4; + let code = if (0xD800..=0xDBFF).contains(&code) && self.s.get(self.i..self.i.checked_add(6)?)?.starts_with(b"\\u") { + let low = hex4(self.s.get(self.i.checked_add(2)?..self.i.checked_add(6)?)?)?; + if (0xDC00..=0xDFFF).contains(&low) { + self.i += 6; + 0x10000 + ((u32::from(code) - 0xD800) << 10) + (u32::from(low) - 0xDC00) + } else { + u32::from(code) + } + } else { + u32::from(code) + }; + let ch = char::from_u32(code).unwrap_or('\u{FFFD}'); + let mut encoded = [0u8; 4]; + out.extend_from_slice(ch.encode_utf8(&mut encoded).as_bytes()); + } + other => out.push(other), + } + } + c if c == q => return Some(String::from_utf8_lossy(&out).into_owned()), + c => out.push(c), + } + } + None + } + + fn arg(&mut self) -> Option { + self.ws(); + let c = *self.s.get(self.i)?; + match c { + b'"' | b'\'' => self.string().map(Arg::Str), + b'[' => { + self.i += 1; + self.nested(b']').map(Arg::List) + } + _ if self.s[self.i..].starts_with(b"new Array") => { + self.i += b"new Array".len(); + self.ws(); + if self.s.get(self.i) != Some(&b'(') { + return None; + } + self.i += 1; + self.nested(b')').map(Arg::List) + } + _ if self.s[self.i..].starts_with(b"true") => { + self.i += 4; + Some(Arg::Bool(true)) + } + _ if self.s[self.i..].starts_with(b"false") => { + self.i += 5; + Some(Arg::Bool(false)) + } + _ => { + let start = self.i; + while self.i < self.s.len() && (self.s[self.i].is_ascii_digit() || matches!(self.s[self.i], b'.' | b'-' | b'+' | b'e' | b'E')) { + self.i += 1; + } + std::str::from_utf8(&self.s[start..self.i]).ok()?.parse().ok().map(Arg::Num) + } + } + } + + /// A list inside another, at most [`MAX_NESTING`] deep. + fn nested(&mut self, close: u8) -> Option> { + if self.depth >= MAX_NESTING { + return None; + } + self.depth += 1; + let out = self.list(close); + self.depth -= 1; + out + } + + fn list(&mut self, close: u8) -> Option> { + let mut out = Vec::new(); + loop { + self.ws(); + if self.s.get(self.i) == Some(&close) { + self.i += 1; + return Some(out); + } + out.push(self.arg()?); + self.ws(); + match self.s.get(self.i) { + Some(b',') => self.i += 1, + Some(c) if *c == close => { + self.i += 1; + return Some(out); + } + _ => return None, + } + } + } +} + +/// The first call to `name(` in `js` and its arguments. +pub(super) fn call(js: &str, name: &str) -> Option> { + let at = js.find(&format!("{name}("))?; + // Not part of a longer identifier (AFDate_Format vs AFDate_FormatEx). + if js[..at].chars().last().is_some_and(|c| c.is_alphanumeric() || c == '_') { + return None; + } + let mut p = Parser { s: js.as_bytes(), i: at + name.len() + 1, depth: 0 }; + p.list(b')') +} + +pub fn parse_format(js: &str) -> Option { + let a = |args: &[Arg], i: usize| args.get(i).and_then(Arg::num).unwrap_or(0.0).clamp(0.0, 255.0) as u8; + if let Some(args) = call(js, "AFNumber_Format") { + return Some(Format::Number { + decimals: a(&args, 0), + sep: a(&args, 1), + neg: a(&args, 2), + currency: args.get(4).and_then(Arg::string).unwrap_or_default(), + prepend: args.get(5).is_none_or(Arg::truthy), + }); + } + if let Some(args) = call(js, "AFPercent_Format") { + return Some(Format::Percent { decimals: a(&args, 0), sep: a(&args, 1) }); + } + if let Some(args) = call(js, "AFDate_FormatEx") { + return Some(Format::Date(args.first().and_then(Arg::string)?)); + } + if let Some(args) = call(js, "AFDate_Format") { + return Some(Format::Date(DATE_PRESETS.get(a(&args, 0) as usize)?.to_string())); + } + if let Some(args) = call(js, "AFTime_FormatEx") { + return Some(Format::Time(args.first().and_then(Arg::string)?)); + } + if let Some(args) = call(js, "AFTime_Format") { + return Some(Format::Time(TIME_PRESETS.get(a(&args, 0) as usize)?.to_string())); + } + if let Some(args) = call(js, "AFSpecial_Format") { + return Some(Format::Special(a(&args, 0).min(3))); + } + if let Some(args) = call(js, "AFSpecial_KeystrokeEx") { + return Some(Format::Mask(args.first().and_then(Arg::string)?)); + } + None +} + +pub fn parse_validate(js: &str) -> Option { + let args = call(js, "AFRange_Validate")?; + let flag = |i: usize| args.get(i).is_some_and(Arg::truthy); + let num = |i: usize| args.get(i).and_then(Arg::num); + Some(Validate::Range { min: if flag(0) { num(1) } else { None }, max: if flag(2) { num(3) } else { None } }) +} + +pub fn parse_calculate(js: &str) -> Option { + // Simplified field notation: Acrobat keeps the expression in a comment. A script is read for + // its comments only when it spells the marker. + if js.contains("BVCALC") + && let Some(range) = notation_range(js, &js_tokens(js).1) + { + return Some(Calculate::Notation(notation_text(js.get(range)?).to_string())); + } + let args = call(js, "AFSimple_Calculate")?; + let op = match args.first().and_then(Arg::string)?.as_str() { + "SUM" => CalcOp::Sum, + "PRD" => CalcOp::Product, + "AVG" => CalcOp::Average, + "MIN" => CalcOp::Minimum, + "MAX" => CalcOp::Maximum, + _ => return None, + }; + let fields = match args.get(1)? { + Arg::List(l) => l.iter().filter_map(Arg::string).collect(), + Arg::Str(s) => s.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect(), + _ => return None, + }; + Some(Calculate::Simple { op, fields }) +} + +// ── writing scripts (what Acrobat's tabs generate) ───────────────────────────────────────── + +pub(super) fn js_str(s: &str) -> String { + let mut o = String::from("\""); + for c in s.chars() { + match c { + '"' | '\\' => { + o.push('\\'); + o.push(c); + } + '\n' => o.push_str("\\n"), + '\r' => o.push_str("\\r"), + '\t' => o.push_str("\\t"), + c if c.is_ascii() && !c.is_control() => o.push(c), + c => { + let mut units = [0u16; 2]; + for unit in c.encode_utf16(&mut units) { + o.push_str(&format!("\\u{unit:04X}")); + } + } + } + } + o.push('"'); + o +} + +/// The (format, keystroke) scripts for a format. +pub fn format_js(f: &Format) -> Option<(String, String)> { + Some(match f { + Format::None => return None, + Format::Number { decimals, sep, neg, currency, prepend } => { + let args = format!("{decimals}, {sep}, {neg}, 0, {}, {prepend}", js_str(currency)); + (format!("AFNumber_Format({args});"), format!("AFNumber_Keystroke({args});")) + } + Format::Percent { decimals, sep } => (format!("AFPercent_Format({decimals}, {sep});"), format!("AFPercent_Keystroke({decimals}, {sep});")), + Format::Date(f) => (format!("AFDate_FormatEx({});", js_str(f)), format!("AFDate_KeystrokeEx({});", js_str(f))), + Format::Time(f) => (format!("AFTime_FormatEx({});", js_str(f)), format!("AFTime_KeystrokeEx({});", js_str(f))), + Format::Special(n) => (format!("AFSpecial_Format({n});"), format!("AFSpecial_Keystroke({n});")), + Format::Mask(m) => (String::new(), format!("AFSpecial_KeystrokeEx({});", js_str(m))), + }) +} + +pub fn validate_js(v: &Validate) -> Option { + match v { + Validate::None => None, + Validate::Range { min, max } => { + Some(format!("AFRange_Validate({}, {}, {}, {});", min.is_some(), min.unwrap_or(0.0), max.is_some(), max.unwrap_or(0.0))) + } + } +} + +/// Why [`notation_storable`] refuses a notation, in the words the Calculate tab and the tools say it. +pub const NOTATION_NOT_STORABLE: &str = "the notation can't be stored as written: it is empty, or holds EVCALC or */, or ends in a lone \\"; + +/// Whether simplified field notation `expr` can be stored: the script keeps it in a comment, +/// between `BVCALC` and `EVCALC`, and it has to read back from there as the same names and +/// operators. One holding `EVCALC` or `*/` ends early, and one ending in a lone `\` escapes the +/// space after it. An empty one is refused too: it would be stored as `event.value = ;`. +pub fn notation_storable(expr: &str) -> bool { + let tokens = |s: &str| notation_tokens(s).into_iter().map(|(range, _)| s.get(range).unwrap_or_default().to_owned()).collect::>(); + let read = calculate_js(&Calculate::Notation(expr.to_owned())).and_then(|js| parse_calculate(&js)); + !tokens(expr).is_empty() && matches!(read, Some(Calculate::Notation(n)) if tokens(&n) == tokens(expr)) +} + +/// `src` from its first notation token to its last: the spaces around it left out, but not an +/// escaped space that ends the last name (`Total\ `), as `str::trim` would. +fn notation_text(src: &str) -> &str { + let tokens = notation_tokens(src); + match (tokens.first(), tokens.last()) { + (Some((first, _)), Some((last, _))) => src.get(first.start..last.end).unwrap_or_default(), + _ => "", + } +} + +pub fn calculate_js(c: &Calculate) -> Option { + match c { + Calculate::None => None, + Calculate::Simple { op, fields } => Some(format!( + "AFSimple_Calculate({}, new Array({}));", + js_str(op.code()), + fields.iter().map(|f| js_str(f)).collect::>().join(", ") + )), + // Acrobat's own translation follows the comment, so a viewer that runs the script instead + // of reading the comment computes the same value (a placeholder here showed 0). + Calculate::Notation(expr) => Some(format!("/** BVCALC {expr} EVCALC **/ event.value = {};", notation_js(expr))), + } +} diff --git a/crates/forms/src/af/tests.rs b/crates/forms/src/af/tests.rs new file mode 100644 index 0000000000..79c5c7be02 --- /dev/null +++ b/crates/forms/src/af/tests.rs @@ -0,0 +1,385 @@ +use super::*; + +#[test] +fn javascript_escapes_are_decoded() { + assert_eq!( + parse_format("AFNumber_Format(2, 0, 0, 0, \"\\x24\", true);"), + Some(Format::Number { decimals: 2, sep: 0, neg: 0, currency: "$".into(), prepend: true }) + ); + assert_eq!(parse_format("AFDate_FormatEx(\"\\b\\f\\v\\0\\q\");"), Some(Format::Date("\u{8}\u{c}\u{b}\0q".into()))); +} + +#[test] +fn scripts_are_recognised_and_written_back() { + assert_eq!( + parse_format("AFNumber_Format(2, 0, 0, 0, \"$\", true);"), + Some(Format::Number { decimals: 2, sep: 0, neg: 0, currency: "$".into(), prepend: true }) + ); + assert_eq!(parse_format("AFPercent_Format(1, 0);"), Some(Format::Percent { decimals: 1, sep: 0 })); + assert_eq!(parse_format("AFDate_FormatEx(\"dd-mmm-yyyy\");"), Some(Format::Date("dd-mmm-yyyy".into()))); + assert_eq!(parse_format("AFDate_Format(2);"), Some(Format::Date("mm/dd/yy".into()))); + assert_eq!(parse_format("AFTime_Format(1);"), Some(Format::Time("h:MM tt".into()))); + assert_eq!(parse_format("AFSpecial_Format(2);"), Some(Format::Special(2))); + assert_eq!(parse_format("AFSpecial_KeystrokeEx(\"AA-9999\");"), Some(Format::Mask("AA-9999".into()))); + assert_eq!(parse_format("app.alert('hi')"), None); + assert_eq!(parse_validate("AFRange_Validate(true, 0, true, 100);"), Some(Validate::Range { min: Some(0.0), max: Some(100.0) })); + assert_eq!(parse_validate("AFRange_Validate(false, 0, true, 10);"), Some(Validate::Range { min: None, max: Some(10.0) })); + assert_eq!( + parse_calculate("AFSimple_Calculate(\"SUM\", new Array (\"a\", \"b.c\"));"), + Some(Calculate::Simple { op: CalcOp::Sum, fields: vec!["a".into(), "b.c".into()] }) + ); + assert_eq!(parse_calculate("AFSimple_Calculate('AVG', ['x']);"), Some(Calculate::Simple { op: CalcOp::Average, fields: vec!["x".into()] })); + assert_eq!( + parse_calculate("/** BVCALC Price * Qty EVCALC **/ event.value = AFMakeNumber(getField(\"Price\").value) * …"), + Some(Calculate::Notation("Price * Qty".into())) + ); + // Round trips through what the tabs write. + for f in [ + Format::Number { decimals: 2, sep: 2, neg: 2, currency: "€".into(), prepend: false }, + Format::Percent { decimals: 0, sep: 0 }, + Format::Date("mmmm d, yyyy".into()), + Format::Special(3), + ] { + let (fj, _) = format_js(&f).unwrap(); + assert_eq!(parse_format(&fj), Some(f)); + } + let euro = format_js(&Format::Number { decimals: 2, sep: 2, neg: 0, currency: "€".into(), prepend: true }).unwrap().0; + assert!(euro.contains("\\u20AC"), "{euro}"); + assert!(!euro.contains('€'), "{euro}"); + let pound = format_js(&Format::Number { decimals: 2, sep: 0, neg: 0, currency: "£".into(), prepend: true }).unwrap().0; + assert!(pound.contains("\\u00A3"), "{pound}"); + assert!(!pound.contains('£'), "{pound}"); + let c = Calculate::Simple { op: CalcOp::Product, fields: vec!["q".into(), "p".into()] }; + assert_eq!(parse_calculate(&calculate_js(&c).unwrap()), Some(c)); + let c = Calculate::Notation("a + b".into()); + assert_eq!(parse_calculate(&calculate_js(&c).unwrap()), Some(c)); + // The JavaScript after the comment is Acrobat's translation, so viewers that run it + // instead of reading the comment compute the same value (not 0). + assert_eq!( + calculate_js(&Calculate::Notation("(Unit\\ Price * Qty) / 2".into())).unwrap(), + "/** BVCALC (Unit\\ Price * Qty) / 2 EVCALC **/ event.value = (AFMakeNumber(this.getField(\"Unit Price\").value) * AFMakeNumber(this.getField(\"Qty\").value)) / 2;" + ); +} + +#[test] +fn a_rename_rewrites_only_where_a_calculation_reads_the_field() { + let renamed = |js: &str| rename_in_calculation(js, "a", "qty"); + // The field lists Acrobat and other tools write for AFSimple_Calculate. + assert_eq!( + renamed("AFSimple_Calculate(\"SUM\", new Array (\"a\", \"b\"));").as_deref(), + Some("AFSimple_Calculate(\"SUM\", new Array (\"qty\", \"b\"));") + ); + assert_eq!(renamed("AFSimple_Calculate('AVG', ['b', 'a']);").as_deref(), Some("AFSimple_Calculate('AVG', ['b', \"qty\"]);")); + assert_eq!(renamed("AFSimple_Calculate(\"SUM\", \"a, b,a\");").as_deref(), Some("AFSimple_Calculate(\"SUM\", \"qty, b,qty\");")); + assert_eq!(renamed("AFSimple_Calculate(\"SUM\", new Array(\"ab\", \"b.a\"));"), None, "other names that contain it"); + // Notation: the expression in the comment and Acrobat's translation after it. + let notation = |expr: &str| calculate_js(&Calculate::Notation(expr.into())).unwrap(); + assert_eq!(rename_in_calculation(¬ation("(a + ab) * a / 2"), "a", "Unit Price"), Some(notation("(Unit\\ Price + ab) * Unit\\ Price / 2"))); + assert_eq!(rename_in_calculation(¬ation("Unit\\ Price * 2"), "Unit Price", "price"), Some(notation("price * 2"))); + // Scripts: a getField whose argument is one string literal. Comments, strings, template + // literals, regular expressions and names built at run time are left as written. + let script = "// getField(\"a\")\nvar s = \"getField('a')\", t = `getField(\"a\")`; /* getField(\"a\") */\n\ + if (/\"/.test(s)) event.value = this.getField('a').value / 2 * getField(\"a\" + 1).value + getField ( \"ab\" ).value;"; + assert_eq!(renamed(script), Some(script.replacen("this.getField('a')", "this.getField(\"qty\")", 1))); + assert_eq!(renamed("event.value = getField(\"b\").value;"), None); + // A new name is escaped as the script needs it. + assert_eq!( + rename_in_calculation("event.value = getField(\"a\").value;", "a", "say \"é\"").as_deref(), + Some("event.value = getField(\"say \\\"\\u00E9\\\"\").value;") + ); + // A name that would end the notation early is not written in. + assert_eq!(rename_in_calculation(¬ation("a * 2"), "a", "qtyEVCALC"), None); + // Unfinished scripts end their tokens, not the program. + for js in ["getField(\"a", "/* getField(\"a\")", "`getField(\"a\")", "x = /[/", "getField('a\\u12"] { + assert_eq!(renamed(js), None, "{js}"); + } +} + +/// The notation is read only from a comment, where Acrobat writes it: a string or code that +/// spells the markers is left alone, by the Calculate tab and by a rename. +#[test] +fn notation_is_read_only_from_a_comment() { + let string = "var s = \"BVCALC a EVCALC\"; event.value = getField(\"b\").value;"; + assert_eq!(parse_calculate(string), None); + assert_eq!(rename_in_calculation(string, "a", "qty"), None); + // A comment after such a string holds the notation. + let both = "var s = 'BVCALC x EVCALC'; /** BVCALC a + 1 EVCALC **/ event.value = 1;"; + assert_eq!(parse_calculate(both), Some(Calculate::Notation("a + 1".into()))); + assert_eq!(rename_in_calculation(both, "x", "y"), None); + assert_eq!(rename_in_calculation(both, "a", "qty"), Some(both.replace("a + 1", "qty + 1"))); + // A line comment is a comment too, and an unfinished one still holds it. + assert_eq!(parse_calculate("// BVCALC a EVCALC\nevent.value = 1;"), Some(Calculate::Notation("a".into()))); + assert_eq!(parse_calculate("/** BVCALC a EVCALC"), Some(Calculate::Notation("a".into()))); +} + +/// A line comment ends at any of JavaScript's line terminators, not only `\n`, so with `\r` line +/// endings the code after one is still code; and a `/` read as starting a regular expression +/// divides after all when the line ends first. +#[test] +fn a_line_comment_ends_at_any_line_terminator() { + for end in ["\n", "\r", "\r\n", "\u{2028}", "\u{2029}"] { + let string = format!("// header{end}var s = \"BVCALC a EVCALC\"; event.value = getField(\"b\").value;"); + assert_eq!(parse_calculate(&string), None, "{end:?}"); + assert_eq!(rename_in_calculation(&string, "a", "qty"), None, "{end:?}"); + let script = format!("// note{end}getField(\"a\").value;"); + assert_eq!(rename_in_calculation(&script, "a", "qty"), Some(script.replace("\"a\"", "\"qty\"")), "{end:?}"); + let divides = format!("var n = i++ / 2;{end}event.value = getField(\"a\").value / 4;"); + assert_eq!(rename_in_calculation(÷s, "a", "qty"), Some(divides.replace("\"a\"", "\"qty\"")), "{end:?}"); + } +} + +/// U+2028 and U+2029 end a word, a number or a regular expression's flags, though as bytes +/// past ASCII they could be part of one, so a `getField` right after one is still a call. +#[test] +fn a_token_ends_at_a_line_separator() { + for end in ["\u{2028}", "\u{2029}"] { + let scripts = ["x", "1", "/r/g"].map(|token| format!("var t = {token}{end}getField(\"a\").value;")); + let renamed = scripts.clone().map(|js| rename_in_calculation(&js, "a", "qty")); + assert_eq!(renamed, scripts.map(|js| Some(js.replace("\"a\"", "\"qty\""))), "{end:?}"); + } +} + +/// A `/` after the condition of `if`, `while`, `for` or `with` starts a regular expression, +/// as after an operator; after a call's parentheses it still divides. +#[test] +fn a_regex_after_a_condition_is_not_read_as_code() { + let renamed = |js: &str| rename_in_calculation(js, "a", "qty"); + assert_eq!(renamed("if (flag) /getField(\"a\")/.test(x); event.value = 1;"), None); + // A quote inside it doesn't start a string that hides the rest of the script. + let quote = "while (i--) /\"/.test(s); event.value = getField(\"a\").value;"; + assert_eq!(renamed(quote), Some(quote.replace("\"a\"", "\"qty\""))); + // The same after the `while` that ends a `do` loop. + let after_do = "do i++; while (i < n) /\"/.test(s); event.value = getField(\"a\").value;"; + assert_eq!(renamed(after_do), Some(after_do.replace("\"a\"", "\"qty\""))); + // After a call, or a method that happens to be called `if`, the `/` divides. + let divides = "event.value = Math.abs(getField(\"b\").value) / 2 + getField(\"a\").value / 3 + o.if(x) / 4 + getField(\"a\").value / 5;"; + assert_eq!(renamed(divides), Some(divides.replace("\"a\"", "\"qty\""))); +} + +/// A `getField` whose argument is more than one string, or an `AFSimple_Calculate` list that +/// isn't written out, reads a field whose name isn't known until the script runs. +#[test] +fn names_built_at_run_time_are_noticed() { + for js in [ + "event.value = getField(\"row\" + i).value;", + "event.value = this.getField(name).value;", + "AFSimple_Calculate(\"SUM\", list);", + "AFSimple_Calculate(\"SUM\", new Array(\"a\", b));", + "AFSimple_Calculate(\"SUM\", [\"a\", prefix + \"b\"]);", + "AFSimple_Calculate(\"SUM\", new Array(\"a\", \"b\" + c));", + "AFSimple_Calculate(op, new Array(\"a\"));", + "AFSimple_Calculate(\"SUM\", \"row\" + suffix);", + "AFSimple_Calculate(\"SUM\", [\"a\"].concat(more));", + "AFSimple_Calculate(\"SUM\", [\"row\" + i]);", + ] { + assert!(reads_built_names(js), "{js}"); + } + // A string that is only the start of a name or a list isn't read, or renamed, as one. + for (js, reads) in [ + ("AFSimple_Calculate(\"SUM\", \"row\" + suffix);", vec![]), + ("AFSimple_Calculate(\"SUM\", [\"row\" + i]);", vec![]), + ("AFSimple_Calculate(\"SUM\", [\"a\", \"row\" + i]);", vec!["a"]), + ("AFSimple_Calculate(\"SUM\", [\"a\", \"b\"].concat(more));", vec!["a", "b"]), + ] { + assert_eq!(parse_calculate(js), None, "{js} is a script"); + assert_eq!(calculation_reads(js), reads, "{js}"); + assert_eq!(rename_in_calculation(js, "row", "line"), None, "{js}"); + } + for js in [ + "event.value = getField(\"a\").value + getField('b').value;", + "AFSimple_Calculate(\"SUM\", new Array(\"a\", \"b\"));", + "AFSimple_Calculate(\"SUM\", [\"a\", \"b\",]);", + "AFSimple_Calculate(\"SUM\", \"a, b\");", + "// getField(\"row\" + i)\nvar s = 'getField(name)'; event.value = 1;", + "/** BVCALC a + b EVCALC **/ event.value = getField(\"a\").value + getField(\"b\").value;", + ] { + assert!(!reads_built_names(js), "{js}"); + } + // The names it does know are still read and renamed. + let js = "event.value = getField(\"a\").value + getField(\"row\" + i).value + AFSimple_Calculate(\"SUM\", new Array(\"a\", b));"; + assert_eq!(calculation_reads(js), ["a"]); + assert_eq!(rename_in_calculation(js, "a", "qty"), Some(js.replace("\"a\"", "\"qty\""))); + // So are a list's, string or array, with another argument after it. + for js in ["AFSimple_Calculate(\"SUM\", \"a, b\", x);", "AFSimple_Calculate(\"SUM\", [\"a\", \"b\"], x);"] { + assert!(reads_built_names(js), "{js}"); + assert_eq!(calculation_reads(js), ["a", "b"], "{js}"); + assert_eq!(rename_in_calculation(js, "a", "qty"), Some(js.replace("\"a", "\"qty")), "{js}"); + } +} + +/// Scripts are read in one pass: a line of unfinished regular expressions, or calls nested in +/// one another's field lists, don't make a rename read the rest of the script again for each. +#[test] +fn hostile_scripts_are_read_in_one_pass() { + let regexes = format!("x = {} getField(\"a\")", "/[".repeat(500_000)); + assert!(rename_in_calculation(®exes, "a", "b").is_some_and(|js| js.ends_with("getField(\"b\")"))); + let nested = format!("{}\"a\"", "AFSimple_Calculate(\"SUM\", new Array(".repeat(100_000)); + assert_eq!(rename_in_calculation(&nested, "a", "b"), Some(format!("{}\"b\"", "AFSimple_Calculate(\"SUM\", new Array(".repeat(100_000)))); +} + +/// A notation is stored only when it reads back from the script's comment as itself. +#[test] +fn a_notation_is_stored_only_when_it_reads_back() { + assert!(notation_storable("Price * Qty")); + assert!(notation_storable(" a + b "), "the spaces around it are left out when it is read"); + // A last name that ends in a space keeps it, escaped, when the notation is read back. + let total = "Qty * Total\\ "; + assert!(notation_storable(total)); + let read = parse_calculate(&calculate_js(&Calculate::Notation(total.into())).unwrap()); + assert_eq!(read.as_ref().map(Calculate::reads), Some(vec!["Qty".to_owned(), "Total ".to_owned()])); + for expr in ["", " ", "a EVCALC b", "a */ x /*", "a */ b", "a\\"] { + assert!(!notation_storable(expr), "{expr:?}"); + } +} + +/// Notation is read through one lexer: field names with escapes, numbers and operators. +#[test] +fn notation_is_read_by_one_lexer() { + let tokens: Vec = notation_tokens("(Unit\\ Price+1.5)*qty_2\\").into_iter().map(|(_, t)| t).collect(); + assert_eq!( + tokens, + [ + Notation::Op('('), + Notation::Name("Unit Price".into()), + Notation::Op('+'), + Notation::Number, + Notation::Op(')'), + Notation::Op('*'), + Notation::Name("qty_2\\".into()) + ] + ); + assert_eq!(notation_js("2 * a\\ b"), "2 * AFMakeNumber(this.getField(\"a b\").value)"); + let values = |name: &str| if name == "Unit Price" { 4.0 } else { 0.5 }; + assert_eq!(eval_notation("(Unit\\ Price + 1.5) * qty", &values), Some(2.75)); + assert_eq!(eval_notation("1.2.3 + a", &values), None, "a number that isn't one"); + assert_eq!(notation_name("2nd + (x)\\"), "\\2nd\\ \\+\\ \\(x\\)\\\\"); +} + +proptest::proptest! { + /// Any field name written in notation reads back as that one name. + #[test] + fn any_name_written_in_notation_reads_back(name in "\\PC{1,12}") { + let written = notation_name(&name); + proptest::prop_assert_eq!(notation_tokens(&written), vec![(0..written.len(), Notation::Name(name))]); + } + + /// Renaming a field in a formula and back gives the formula as it was. + #[test] + fn a_rename_round_trips(old in "\\PC{1,8}", new in "\\PC{1,8}", others in proptest::collection::vec("\\PC{1,8}", 0..4), sum in proptest::bool::ANY) { + proptest::prop_assume!(old != new && !others.contains(&new) && ![&old, &new].into_iter().chain(&others).any(|n| n.contains("VCALC"))); + let names: Vec = std::iter::once(old.clone()).chain(others).collect(); + let js = if sum { + calculate_js(&Calculate::Simple { op: CalcOp::Sum, fields: names }).unwrap() + } else { + calculate_js(&Calculate::Notation(names.iter().map(|n| notation_name(n)).collect::>().join(" * "))).unwrap() + }; + let renamed = rename_in_calculation(&js, &old, &new); + proptest::prop_assert!(renamed.is_some(), "{js}"); + let back = renamed.as_deref().and_then(|r| rename_in_calculation(r, &new, &old)); + proptest::prop_assert_eq!(back, Some(js)); + } + + /// A notation of any names, written into a script, reads back from it as written. + #[test] + fn a_notation_reads_back_as_written(names in proptest::collection::vec("\\PC{1,8}", 1..5)) { + proptest::prop_assume!(!names.iter().any(|n| n.contains("VCALC"))); + let expr = names.iter().map(|n| notation_name(n)).collect::>().join(" + "); + let js = calculate_js(&Calculate::Notation(expr.clone())).unwrap(); + proptest::prop_assert_eq!(parse_calculate(&js), Some(Calculate::Notation(expr.clone()))); + proptest::prop_assert!(notation_storable(&expr), "{expr:?}"); + } + + /// What a formula reads is the same whether it is read from the tab's settings or from + /// the script they are written as. + #[test] + fn a_formula_reads_the_same_fields_as_its_script(names in proptest::collection::vec("\\PC{1,8}", 1..5), sum in proptest::bool::ANY) { + proptest::prop_assume!(!names.iter().any(|n| n.contains("VCALC"))); + let c = if sum { + Calculate::Simple { op: CalcOp::Product, fields: names.clone() } + } else { + Calculate::Notation(names.iter().map(|n| notation_name(n)).collect::>().join(" + ")) + }; + let js = calculate_js(&c).unwrap(); + proptest::prop_assert_eq!(calculation_reads(&js), c.reads()); + proptest::prop_assert_eq!(c.reads(), unique(names)); + } +} + +#[test] +fn a_calculation_reads_each_named_field_once() { + assert_eq!(Calculate::Simple { op: CalcOp::Sum, fields: vec!["a".into(), "b".into(), "a".into()] }.reads(), ["a", "b"]); + assert_eq!(Calculate::Notation("(Unit\\ Price + 2) * qty / qty".into()).reads(), ["Unit Price", "qty"]); + assert_eq!(Calculate::None.reads(), Vec::::new()); + let script = "var t = getField(\"sub\").value; // getField(\"x\")\nevent.value = t + getField(\"tax\").value + getField(\"row\" + 1).value;"; + assert_eq!(calculation_reads(script), ["sub", "tax"], "names built at run time aren't known"); + assert_eq!(calculation_reads("AFSimple_Calculate(\"SUM\", \"a, b\");"), ["a", "b"]); +} + +#[test] +fn numbers_format_like_acrobat() { + let n = |d, s, ng, c: &str, p| Format::Number { decimals: d, sep: s, neg: ng, currency: c.into(), prepend: p }; + assert_eq!(format_value(&n(2, 0, 0, "$", true), "1234.5"), "$1,234.50"); + assert_eq!(format_value(&n(0, 1, 0, "", true), "1234567"), "1234567"); + assert_eq!(format_value(&n(2, 2, 0, " €", false), "1234.5"), "1.234,50 €"); + assert_eq!(format_value(&n(2, 0, 2, "", true), "-12"), "(12.00)"); + assert_eq!(format_value(&n(1, 0, 0, "", true), "-0.25"), "-0.3"); + assert_eq!(format_value(&Format::Percent { decimals: 1, sep: 0 }, "0.125"), "12.5%"); + assert_eq!(make_number("$1,234.50"), Some(1234.5)); + assert_eq!(make_number("1.234,5"), Some(1234.5)); + assert_eq!(make_number("(7)"), Some(-7.0)); + assert_eq!(make_number("abc"), None); + assert_eq!(keystroke(&n(2, 0, 0, "$", true), "Total", "$1,000"), Ok("1000".into())); + assert_eq!(keystroke(&n(2, 0, 0, "$", true), "Total", "ten"), Err("The value entered does not match the format of the field [ Total ]".into())); + // Blank input clears a formatted field (#1323); without a format it is text like any other. + assert_eq!(keystroke(&n(2, 0, 0, "$", true), "Total", " \t"), Ok(String::new())); + assert_eq!(keystroke(&Format::None, "Name", " \t"), Ok(" \t".into())); +} + +#[test] +fn dates_times_and_masks() { + let d = Format::Date("mm/dd/yyyy".into()); + assert_eq!(format_value(&d, "1/2/24"), "01/02/2024"); + assert_eq!(format_value(&Format::Date("mmmm d, yyyy".into()), "10/1/2026"), "October 1, 2026"); + assert_eq!(format_value(&Format::Date("dddd, mmm d, yyyy".into()), "10/1/2026"), "Thursday, Oct 1, 2026"); + assert_eq!(format_value(&Format::Date("dd-mmm-yy".into()), "5-Mar-21"), "05-Mar-21", "month names are read"); + assert!(keystroke(&d, "Due", "2/30/2024").is_err(), "no 30 February"); + assert!(keystroke(&d, "Due", "13/1/2024").is_err()); + // Literal digits in the format (a leave form fixing the year as "dd.mm.26"). + let fixed = Format::Date("dd.mm.26".into()); + assert_eq!(keystroke(&fixed, "Von", "15.05.26"), Ok("15.05.26".into())); + assert_eq!(format_value(&fixed, "15.05.26"), "15.05.26"); + assert_eq!(format_value(&fixed, "3.1"), "03.01.26", "the literal may be left out"); + assert!(keystroke(&fixed, "Von", "15.05.27").is_err(), "a different number there is not the literal"); + assert!(keystroke(&fixed, "Von", "32.05.26").is_err()); + assert_eq!(format_value(&Format::Time("h:MM tt".into()), "14:05"), "2:05 pm"); + assert_eq!(format_value(&Format::Time("HH:MM".into()), "2:05 pm"), "14:05"); + assert_eq!(format_value(&Format::Special(2), "5551234567"), "(555) 123-4567"); + assert_eq!(format_value(&Format::Special(3), "123-45-6789"), "123-45-6789"); + assert!(keystroke(&Format::Special(0), "Zip", "1234").is_err()); + assert_eq!(format_value(&Format::Mask("AA-9999".into()), "ab1234"), "ab-1234"); + assert!(keystroke(&Format::Mask("AA-9999".into()), "Code", "a11234").is_err()); +} + +#[test] +fn validation_and_calculation() { + let r = Validate::Range { min: Some(0.0), max: Some(100.0) }; + assert!(validate(&r, "50").is_ok() && validate(&r, "").is_ok()); + assert_eq!(validate(&r, "120"), Err("Invalid value: must be greater than or equal to 0 and less than or equal to 100.".into())); + let vals = |name: &str| -> Vec { + match name { + "a" => vec!["2".into()], + "b" => vec!["$3.50".into()], + "row" => vec!["1".into(), "2".into(), "3".into()], + "Unit Price" => vec!["4".into()], + _ => vec![], + } + }; + let simple = |op, f: &[&str]| calculate(&Calculate::Simple { op, fields: f.iter().map(|s| s.to_string()).collect() }, &vals); + assert_eq!(simple(CalcOp::Sum, &["a", "b"]), Some("5.5".into())); + assert_eq!(simple(CalcOp::Product, &["a", "b"]), Some("7".into())); + assert_eq!(simple(CalcOp::Average, &["row"]), Some("2".into()), "groups count every child"); + assert_eq!(simple(CalcOp::Maximum, &["a", "row"]), Some("3".into())); + assert_eq!(simple(CalcOp::Sum, &["missing"]), Some("0".into())); + assert_eq!(calculate(&Calculate::Notation("(a + b) * 2 - Unit\\ Price / 4".into()), &vals), Some("10".into())); + assert_eq!(calculate(&Calculate::Notation("a +".into()), &vals), None); +} diff --git a/crates/forms/src/appearance.rs b/crates/forms/src/appearance.rs index c1bb5630a6..c9ff86d17a 100644 --- a/crates/forms/src/appearance.rs +++ b/crates/forms/src/appearance.rs @@ -40,9 +40,11 @@ pub fn fmt(v: f64) -> String { } /// The font size an auto-sized (`0 Tf`) text field uses for `text` in a `width` × `height` box -/// (inside the padding), measured by `width_of(text, size)`: at most 12 pt and at least 4 pt. -/// Single-line text shrinks until it fits the width; multiline text takes the largest size whose -/// wrapped lines fit the height. The page appearance and the on-screen editor share this. +/// (inside the padding), measured by `width_of(text, size)`, never under 4 pt. Single-line text +/// takes the size the height gives, as Acrobat Reader draws it however tall the box (the size is +/// implementation-defined, ISO 32000-2 §12.7.4.3), and shrinks until it fits the width; multiline +/// text takes the largest size up to 12 pt whose wrapped lines fit the height. The page +/// appearance and the on-screen editor share this. pub fn auto_text_size(text: &str, width: f64, height: f64, multiline: bool, width_of: impl Fn(&str, f64) -> f64) -> f64 { let wrap_width = if width.is_finite() { width.max(1.0) } else { 1.0 }; auto_text_size_by( @@ -54,6 +56,17 @@ pub fn auto_text_size(text: &str, width: f64, height: f64, multiline: bool, widt ) } +/// The automatic font size of a comb field (`Field::is_comb`) whose cells are `height` tall inside +/// the padding: 4 to 12 pt, as only the height bounds a one-character cell. The page appearance +/// and the on-screen editor share this. +pub fn comb_auto_text_size(height: f64) -> f64 { + if height.is_finite() { (height / 1.15).clamp(4.0, 12.0) } else { 4.0 } +} + +/// The tallest box single-line auto-size follows, as a sanity bound: the largest page side +/// ISO 32000-1 Annex C recommends. +pub(crate) const MAX_AUTO_HEIGHT: f64 = 14_400.0; + /// [`auto_text_size`] with the text measured by the caller: `line_width(size)` is its width on /// one line, `wrapped_height(size)` its height wrapped at `width`. The screen measures in its /// own font, whose widths and line breaks differ from the page's. @@ -67,7 +80,7 @@ pub fn auto_text_size_by(width: f64, height: f64, multiline: bool, line_width: i } return size; } - let size = (height / 1.15).clamp(4.0, 12.0); + let size = (height.min(MAX_AUTO_HEIGHT) / 1.15).max(4.0); let tw = line_width(size); if tw.is_finite() && tw > width { (size * width / tw).max(4.0) } else { size } } @@ -109,7 +122,7 @@ pub fn text_box(doc: &Document, f: &Field, w: &Widget) -> TextBox { size: (da.size > 0.0 && da.size.is_finite()).then_some(da.size), multiline, password: f.has(flags::PASSWORD), - comb: f.has(flags::COMB) && !multiline && !f.has(flags::PASSWORD) && f.max_len.is_some_and(|m| m > 0), + comb: f.is_comb(), } } @@ -586,7 +599,7 @@ fn comb_dividers(doc: &Document, wd: &Dict, w: f64, h: f64, cells: usize) -> Str let inset = border_width(doc, wd); let lw = line_width(doc, wd); let Some(bc) = color_array(doc, mk.as_ref(), b"BC") else { return String::new() }; - if !(inset > 0.0 && inset.is_finite() && lw > 0.0 && lw.is_finite()) || !(2..=MAX_COMB_DIVIDERS).contains(&cells) { + if !(inset > 0.0 && inset.is_finite() && lw > 0.0 && lw.is_finite() && (2..=MAX_COMB_DIVIDERS).contains(&cells)) { return String::new(); } let cell = w / cells as f64; @@ -813,7 +826,7 @@ fn field_appearance_fit(doc: &Document, f: &Field, w: &Widget, values: &[String] } } _ => { - let comb = f.has(flags::COMB) && !f.has(flags::MULTILINE) && !f.has(flags::PASSWORD) && f.max_len.is_some_and(|m| m > 0); + let comb = f.is_comb(); if f.kind == FieldKind::Text && f.has(flags::MULTILINE) { if size == 0.0 { size = auto_text_size(&text, inner_w, height - 2.0 * pad, true, width_of); @@ -840,9 +853,8 @@ fn field_appearance_fit(doc: &Document, f: &Field, w: &Widget, values: &[String] } } else { if size == 0.0 { - // Comb cells hold one character each, so only the height limits them. size = if comb { - ((height - 2.0 * pad) / 1.15).clamp(4.0, 12.0) + comb_auto_text_size(height - 2.0 * pad) } else { auto_text_size(&text, inner_w, height - 2.0 * pad, false, width_of) }; diff --git a/crates/forms/src/appearances.rs b/crates/forms/src/appearances.rs new file mode 100644 index 0000000000..64569fa3ca --- /dev/null +++ b/crates/forms/src/appearances.rs @@ -0,0 +1,237 @@ +use super::read::{acroform, fields}; +use super::values::merged_appearance; +use super::{Dict, Document, FieldKind, ObjRef, Object, Widget, appearance, name_bytes}; + +/// Whether the form asks viewers to draw its fields' appearances themselves (`/NeedAppearances`). +pub fn need_appearances(doc: &Document) -> bool { + acroform(doc).is_some_and(|af| matches!(af.get(b"NeedAppearances").map(|o| doc.resolve(o)).as_deref(), Some(Object::Bool(true)))) +} + +/// A copy of `doc` in which form fields are drawn the way the file leaves to the viewer, for +/// displaying the document (`None` when no widget needs it). Text and choice fields are drawn from +/// their values: every one when the form sets `/NeedAppearances` (ISO 32000-2 Table 224: the +/// writer left the appearances to the viewer), and otherwise each widget without a normal +/// appearance (`/AP /N`), as typing the value would draw it (#3470). Check boxes and radio buttons +/// with no normal appearance for their state (`/AS`) are drawn from their `/MK` background, border +/// and caption, and with `/NeedAppearances` every one that has an `/MK` is, over its own +/// appearance; push buttons with none are drawn from their `/MK` and `/BS` (Table 192). `doc` is +/// not changed: saving writes the file as it was. +pub fn with_field_appearances(doc: &Document) -> Option { + with_field_appearances_cached(doc, &mut FieldAppearanceCache::default()) +} + +/// [`with_field_appearances`], reusing the appearances `cache` drew for widgets whose inputs are +/// the same objects as then (see [`FieldAppearanceCache`]). +pub fn with_field_appearances_cached(doc: &Document, cache: &mut FieldAppearanceCache) -> Option { + let all = need_appearances(doc); + let mut copy: Option = None; + for f in fields(doc) { + for w in &f.widgets { + let widget = doc.get(w.obj); + let normal = widget + .as_dict() + .and_then(|d| d.get(b"AP")) + .and_then(|ap| doc.resolve(ap).as_dict().and_then(|ap| ap.get(b"N").map(|n| doc.resolve(n)))); + // A check box or radio button shows the appearance of its state (12.7.5.2.3): `/AS`, + // else the value when it names this widget's on state (any check box value when the + // widget names none), else Off. An Off state may go without one (it shows nothing); + // an on state needs one. + let state = match f.kind { + FieldKind::CheckBox | FieldKind::Radio => { + let mine = |v: &&String| w.on_state.as_ref().map_or(f.kind == FieldKind::CheckBox, |on| on == *v); + let value = f.value.first().filter(|v| !v.is_empty() && v.as_str() != "Off").filter(mine); + Some(w.state.clone().or_else(|| value.cloned()).unwrap_or_else(|| "Off".into())) + } + _ => None, + }; + // With /NeedAppearances a check box or radio button that describes its look in /MK is + // drawn from it (Table 192), its own appearance or not; one without /MK keeps its own. + let described = || widget.as_dict().and_then(|d| d.get(b"MK")).is_some_and(|mk| doc.resolve(mk).as_dict().is_some()); + let missing = match f.kind { + FieldKind::Text | FieldKind::Combo | FieldKind::List => normal.is_none() || all, + FieldKind::PushButton => normal.is_none(), + FieldKind::CheckBox | FieldKind::Radio => match (normal.as_deref(), state.as_deref()) { + _ if all && described() => true, + (None, _) => true, + (Some(Object::Dict(states)), Some(s)) => s != "Off" && !states.contains(&name_bytes(s)), + _ => false, + }, + FieldKind::Signature => false, + }; + if !missing { + continue; + } + let inputs = appearance_inputs(doc, w); + let stream = match cache.get(w.obj, &inputs) { + Some(stream) => stream, + None => { + let stream = match f.kind { + FieldKind::CheckBox | FieldKind::Radio => appearance::button_state_shown(doc, &f, w, state.as_deref() != Some("Off")), + FieldKind::PushButton => appearance::push_button_shown(doc, &f, w), + _ => appearance::field_appearance_drawn(doc, &f, w, &f.value, true).0, + }; + cache.insert(w.obj, inputs, &stream); + stream + } + }; + let target = copy.get_or_insert_with(|| doc.clone()); + let ap = target.add(Object::Stream(stream)); + let mut fresh = Dict::new(); + match &state { + // The drawn look under its state's name, and that state as the widget's own. + Some(s) => { + let mut states = Dict::new(); + states.set(name_bytes(s), Object::Ref(ap)); + fresh.set(b"N".to_vec(), Object::Dict(states)); + } + None => fresh.set(b"N".to_vec(), Object::Ref(ap)), + } + let apd = merged_appearance(target, w.obj, &fresh); + // Fails only when the widget isn't a dictionary; that widget then keeps its own look. + target + .update_dict(w.obj, |d| { + d.set(b"AP".to_vec(), Object::Dict(apd)); + if let Some(s) = &state { + d.set(b"AS".to_vec(), Object::Name(name_bytes(s))); + } + }) + .ok(); + } + } + copy +} + +/// The objects a widget's drawn appearance is built from, as the document holds them: the widget +/// and the fields above it (value, `/DA`, `/Q`, flags, options, `/MK`, formatting actions), the +/// form dictionary (`/DA`) and the fonts of its `/DR`, each with the objects its entries refer to +/// (an indirect `/MK` or `/V`). Not the page: a field's rotation is its own `/MK /R`. +fn appearance_inputs(doc: &Document, w: &Widget) -> Vec> { + // The objects a dictionary's entries refer to. The tree links and the drawn appearance itself + // are not inputs, and `/Kids` and `/Fields` can be long. + let referred = |d: &Dict, inputs: &mut Vec>| { + for (k, v) in d.iter() { + if !matches!(&k[..], b"Kids" | b"Fields" | b"P" | b"Parent" | b"AP") + && let Some(r) = v.as_ref() + { + inputs.push(doc.get(r)); + } + } + }; + let mut inputs = Vec::new(); + let mut seen = std::collections::HashSet::new(); + let mut node = Some(w.obj); + // The /Parent chain; the seen-set and the cap end a cycle or an absurd depth. + while let Some(r) = node.filter(|r| seen.insert(*r) && seen.len() <= 64) { + let obj = doc.get(r); + if let Some(d) = obj.as_dict() { + node = d.reference(b"Parent"); + referred(d, &mut inputs); + } else { + node = None; + } + inputs.push(obj); + } + let Some(root) = doc.root() else { return inputs }; + let catalog = doc.get(root); + let form = match catalog.as_dict().and_then(|c| c.reference(b"AcroForm")) { + Some(r) => doc.get(r), + None => catalog, + }; + if let Some(f) = form.as_dict() { + referred(f, &mut inputs); + // The fonts of /DR. An indirect /DR or /Font is listed through the entries that refer to + // it; a direct one is part of the object that holds it. `held` only finds them: a copy + // of a direct object is a new one each time, never an input. + let held = |o: &Object| o.as_ref().map_or_else(|| std::sync::Arc::new(o.clone()), |r| doc.get(r)); + if let Some(dr) = f.get(b"DR").map(held) + && let Some(dr) = dr.as_dict() + { + referred(dr, &mut inputs); + if let Some(font) = dr.get(b"Font").map(held) + && let Some(fonts) = font.as_dict() + { + referred(fonts, &mut inputs); + } + } + } + inputs.push(form); + inputs +} + +/// Field appearances drawn for the display copy ([`with_field_appearances_cached`]), so a refresh +/// redraws only the widgets whose inputs changed: a form that leaves every appearance to the +/// viewer otherwise redrew all of its fields on each keystroke in any one of them (#3470). An +/// entry holds the input objects it was drawn from and is used only while each is still the same +/// object (`Arc::ptr_eq`); the document replaces an object it edits, so a changed input is a +/// miss, never a stale hit. +#[derive(Clone, Debug, Default)] +pub struct FieldAppearanceCache { + entries: std::collections::HashMap>, pdfcraft_cos::Stream)>, + bytes: usize, + hits: u64, + builds: u64, +} + +impl FieldAppearanceCache { + /// Cached appearance data restarts past this instead of growing without bound. + const MAX_BYTES: usize = 64 << 20; + + /// Forget every entry. + pub fn clear(&mut self) { + *self = Self::default(); + } + + fn get(&mut self, w: ObjRef, inputs: &[std::sync::Arc]) -> Option { + let (from, stream) = self.entries.get(&w)?; + if from.len() != inputs.len() || !from.iter().zip(inputs).all(|(a, b)| std::sync::Arc::ptr_eq(a, b)) { + return None; + } + self.hits += 1; + Some(stream.clone()) + } + + fn insert(&mut self, w: ObjRef, inputs: Vec>, stream: &pdfcraft_cos::Stream) { + self.builds += 1; + if self.bytes.saturating_add(stream.raw.len()) > Self::MAX_BYTES { + self.clear(); + } + self.bytes = self.bytes.saturating_add(stream.raw.len()); + if let Some((_, old)) = self.entries.insert(w, (inputs, stream.clone())) { + self.bytes = self.bytes.saturating_sub(old.raw.len()); + } + } + + /// Cache hits so far (reused appearances); for tests. + pub fn hits(&self) -> u64 { + self.hits + } + + /// Appearances drawn so far; for tests. + pub fn builds(&self) -> u64 { + self.builds + } +} + +/// Whether the document's XMP metadata declares PDF/A conformance (`pdfaid:part`). +pub fn declares_pdfa(doc: &Document) -> bool { + let Some(root) = doc.root() else { return false }; + let Some(m) = doc.get(root).as_dict().and_then(|c| c.get(b"Metadata").cloned()) else { return false }; + let m = doc.resolve(&m); + match &*m { + Object::Stream(s) => s.decoded().is_ok_and(|b| b.windows(11).any(|w| w == b"pdfaid:part")), + _ => false, + } +} + +/// Every field with a widget whose appearance PdfCraft drew with `?` for a character its font +/// can't hold (`pdfcraft_fonts::UNDRAWN_MARK`): the field's name and that character, once per +/// field. +pub fn undrawn_fields(doc: &Document) -> Vec<(String, char)> { + fields(doc) + .into_iter() + .filter_map(|f| { + let c = f.widgets.iter().find_map(|w| doc.get(w.obj).as_dict().and_then(pdfcraft_fonts::undrawn_mark))?; + Some((f.name, c)) + }) + .collect() +} diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs index 215bd095fc..de597187d4 100644 --- a/crates/forms/src/author.rs +++ b/crates/forms/src/author.rs @@ -10,6 +10,20 @@ use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream}; use crate::{Field, FieldKind, FormError, Widget, appearance, fields, flags, page_refs}; +mod edit; +mod join; +mod lifecycle; +mod look; +mod tree; + +pub(crate) use tree::ancestor_sets; + +pub use edit::set_props; +pub use lifecycle::{add_field, delete_field, delete_field_object, delete_widget, duplicate_field}; +pub(crate) use look::button_appearance; +pub use look::{BorderStyle, CheckStyle, FieldFont, Look, LookPatch, check_style, look, redraw_field, set_button_icon}; +pub(crate) use tree::{ensure_form, form_fonts_as_objects}; + /// The smallest width and height of a field's widget, in points: a smaller one is refused. pub const MIN_FIELD_SIDE: f64 = 4.0; @@ -74,6 +88,9 @@ pub struct FieldProps { pub max_len: Option>, /// Choice options (display texts; export values equal them). pub options: Option>, + /// Change one button widget's export value without changing its appearance state. + /// Widgets sharing that state are kept in sync. The index is zero-based. + pub export_value: Option<(usize, String)>, /// Font size (0 = auto). pub font_size: Option, /// Position: move or resize one widget (its index in [`Field::widgets`]) to a new rect in @@ -113,1599 +130,3 @@ fn invalid(m: impl Into) -> Result { fn rgb(c: [f64; 3]) -> Object { Object::Array(c.iter().map(|v| Object::Real(*v)).collect()) } - -/// The AcroForm dictionary's reference, creating the form (with `/Helv` in `/DR`) if needed. -pub(crate) fn ensure_form(doc: &mut Document) -> Result { - let root = doc.root().ok_or(FormError::NoForm)?; - let existing = doc.get(root).as_dict().and_then(|d| d.get(b"AcroForm").cloned()); - let r = match existing { - Some(Object::Ref(r)) if doc.get(r).as_dict().is_some() => r, - other => { - let d = other.and_then(|o| o.as_dict().cloned()).unwrap_or_default(); - let r = doc.add(Object::Dict(d)); - doc.update_dict(root, |c| c.set(b"AcroForm".to_vec(), Object::Ref(r)))?; - r - } - }; - let mut af = doc.get(r).as_dict().cloned().unwrap_or_default(); - if !af.contains(b"Fields") { - af.set(b"Fields".to_vec(), Object::Array(Vec::new())); - } - if !af.contains(b"DA") { - af.set(b"DA".to_vec(), PdfString::literal(b"/Helv 0 Tf 0 g".to_vec())); - } - let (dr, mut fonts) = form_fonts(doc, &af); - let mut added = inline_fonts_as_objects(doc, &mut fonts); - // Acrobat's resource names for the standard fonts its Appearance tab offers. - for (name, base) in [("Helv", "Helvetica"), ("TiRo", "Times-Roman"), ("Cour", "Courier")] { - if fonts.contains(name.as_bytes()) { - continue; - } - let mut f = Dict::new(); - f.set(b"Type".to_vec(), Object::name("Font")); - f.set(b"Subtype".to_vec(), Object::name("Type1")); - f.set(b"BaseFont".to_vec(), Object::name(base)); - f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); - let r = doc.add(Object::Dict(f)); - fonts.set(name.as_bytes().to_vec(), Object::Ref(r)); - added = true; - } - if added { - store_form_fonts(doc, &mut af, dr, fonts); - } - doc.set(r, Object::Dict(af)); - Ok(r) -} - -/// The form dictionary's `/DR` and its `/Font` dictionary, resolved (empty where missing). -fn form_fonts(doc: &Document, af: &Dict) -> (Dict, Dict) { - let dr = af.get(b"DR").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default(); - let fonts = dr.get(b"Font").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default(); - (dr, fonts) -} - -/// Each /DR font its own object: Acrobat, redrawing a text field after it is typed in, shows -/// nothing when the font is a dictionary written inline in /DR (#3441). Fonts written inline -/// (by an earlier save, or another writer) become objects saying the same; a font that is an -/// object already, or an entry that isn't a dictionary, is left alone. Whether any changed. -fn inline_fonts_as_objects(doc: &mut Document, fonts: &mut Dict) -> bool { - let inline: Vec<(Vec, Dict)> = fonts.iter().filter_map(|(k, v)| Some((k.clone(), v.as_dict()?.clone()))).collect(); - let changed = !inline.is_empty(); - for (name, font) in inline { - let r = doc.add(Object::Dict(font)); - fonts.set(name, Object::Ref(r)); - } - changed -} - -/// Write `fonts` back as the `/DR /Font` of the form dictionary `af`. An indirect `/DR` or -/// `/Font` stays the object it is. -fn store_form_fonts(doc: &mut Document, af: &mut Dict, mut dr: Dict, fonts: Dict) { - match dr.get(b"Font").and_then(Object::as_ref) { - Some(fr) => doc.set(fr, Object::Dict(fonts)), - None => dr.set(b"Font".to_vec(), Object::Dict(fonts)), - } - match af.get(b"DR").and_then(Object::as_ref) { - Some(dr_ref) => doc.set(dr_ref, Object::Dict(dr)), - None => af.set(b"DR".to_vec(), Object::Dict(dr)), - } -} - -/// Filling a field: the form's inline `/DR` fonts become objects (see -/// [`inline_fonts_as_objects`]), so a form an earlier version of PdfCraft made works in Acrobat -/// once it has been filled here too (#3441). Only the font storage changes; nothing is added. -/// Not in a form a signature covers: there the file stays as the signer saw it, apart from the -/// value itself. -pub(crate) fn form_fonts_as_objects(doc: &mut Document, all: &[crate::Field]) -> Result<(), FormError> { - let signed = all.iter().any(|f| f.kind == crate::FieldKind::Signature && holds_value(doc, f.obj)); - if signed { - return Ok(()); - } - let Some(root) = doc.root() else { return Ok(()) }; - let Some(entry) = doc.get(root).as_dict().and_then(|c| c.get(b"AcroForm").cloned()) else { return Ok(()) }; - match entry { - Object::Ref(r) => { - let Some(mut af) = doc.get(r).as_dict().cloned() else { return Ok(()) }; - let (dr, mut fonts) = form_fonts(doc, &af); - if inline_fonts_as_objects(doc, &mut fonts) { - store_form_fonts(doc, &mut af, dr, fonts); - doc.set(r, Object::Dict(af)); - } - } - // A form dictionary written directly in the catalog (the catalog's /AcroForm need not be - // an indirect reference) is written back there. - Object::Dict(mut af) => { - let (dr, mut fonts) = form_fonts(doc, &af); - if inline_fonts_as_objects(doc, &mut fonts) { - store_form_fonts(doc, &mut af, dr, fonts); - doc.update_dict(root, |c| c.set(b"AcroForm".to_vec(), Object::Dict(af)))?; - } - } - _ => {} - } - Ok(()) -} - -/// Whether the field `r`, or the nearest ancestor that has one, holds a `/V` (the entry is -/// inheritable, ISO 32000-2 §12.7.4). The `/Parent` chain is followed at most 64 deep, as deep -/// as the field walk goes, so a cyclic chain ends. -fn holds_value(doc: &Document, r: ObjRef) -> bool { - let mut at = r; - for _ in 0..=64 { - let obj = doc.get(at); - let Some(d) = obj.as_dict() else { return false }; - if d.contains(b"V") { - return true; - } - match d.reference(b"Parent") { - Some(p) => at = p, - None => return false, - } - } - false -} - -/// The Appearance tab: borders, colours and text. -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct Look { - pub border: Option<[f64; 3]>, - pub fill: Option<[f64; 3]>, - /// Line thickness in points (Acrobat: thin 1, medium 2, thick 3). - pub width: f64, - pub style: BorderStyle, - pub text: [f64; 3], - pub font: FieldFont, -} - -/// A partial Appearance-tab edit. Outer `None` keeps a property; `Some(None)` removes a -/// border or fill colour. The patch is applied to each widget's own dictionaries. -#[derive(Clone, Copy, Debug, Default, PartialEq)] -pub struct LookPatch { - pub border: Option>, - pub fill: Option>, - pub width: Option, - pub style: Option, - pub text: Option<[f64; 3]>, - pub font: Option, -} - -impl LookPatch { - fn validate(&self) -> Result<(), FormError> { - for c in self.border.flatten().into_iter().chain(self.fill.flatten()).chain(self.text) { - if !c.iter().all(|v| v.is_finite() && (0.0..=1.0).contains(v)) { - return invalid("appearance colours must be finite RGB values between 0 and 1"); - } - } - if self.width.is_some_and(|v| !v.is_finite() || !(0.0..=12.0).contains(&v)) { - return invalid("border width must be between 0 and 12 points"); - } - Ok(()) - } -} - -/// Rewrite only the selected operators in place: the last `Tf` (font and size) and the last -/// fill colour (`g`, `rg` or `k`), appending one when the string has none. Everything else (a -/// custom font resource, colour space or other graphics state) is kept, and repeated edits don't -/// grow the string. -fn patch_da(da: &str, size: Option, patch: &LookPatch) -> String { - if patch.font.is_none() && size.is_none() && patch.text.is_none() { - return da.to_string(); - } - let mut toks: Vec = da.split_whitespace().map(str::to_string).collect(); - let numeric = |s: &str| s.parse::().is_ok(); - if patch.font.is_some() || size.is_some() { - let old = appearance::parse_da(da); - let font = patch.font.map_or_else(|| old.font.clone(), |f| f.resource().to_string()); - // `parse_da` reads sizes up to 300 points; keep the same bound when writing. - let size = appearance::fmt(size.unwrap_or(old.size).clamp(0.0, 300.0)); - let at = toks.iter().enumerate().rev().find(|(i, t)| t.as_str() == "Tf" && *i >= 2).map(|(i, _)| i); - match at { - Some(i) => { - if let Some(slot) = toks.get_mut(i - 2) { - *slot = format!("/{font}"); - } - if let Some(slot) = toks.get_mut(i - 1) { - *slot = size; - } - } - None => toks.extend([format!("/{font}"), size, "Tf".into()]), - } - } - if let Some(c) = patch.text { - let rgb = [appearance::fmt(c[0]), appearance::fmt(c[1]), appearance::fmt(c[2]), "rg".into()]; - let at = toks.iter().enumerate().rev().find_map(|(i, t)| { - let n = match t.as_str() { - "g" => 1, - "rg" => 3, - "k" => 4, - _ => return None, - }; - let start = i.checked_sub(n)?; - toks.get(start..i).is_some_and(|ops| ops.iter().all(|o| numeric(o))).then_some((start, i)) - }); - match at { - Some((start, end)) => { - toks.splice(start..=end, rgb); - } - None => toks.extend(rgb), - } - } - toks.join(" ") -} - -fn patch_widget_look(doc: &mut Document, w: &Widget, patch: &LookPatch) -> Result<(), FormError> { - let touches_mk = patch.border.is_some() || patch.fill.is_some(); - let touches_bs = patch.width.is_some() || patch.style.is_some(); - // A size- or font-only edit changes /DA, not the widget: leave it out of the save. - if !touches_mk && !touches_bs { - return Ok(()); - } - let wd = doc.get(w.obj).as_dict().cloned().ok_or_else(|| FormError::Invalid("the widget is not a dictionary".into()))?; - if touches_mk { - let indirect = wd.get(b"MK").and_then(Object::as_ref); - let mut mk = wd.get(b"MK").map(|m| doc.resolve(m)).and_then(|m| m.as_dict().cloned()).unwrap_or_default(); - for (key, change) in [(b"BC".as_slice(), patch.border), (b"BG".as_slice(), patch.fill)] { - if let Some(change) = change { - match change { - Some(c) => mk.set(key.to_vec(), rgb(c)), - None => { - mk.remove(key); - } - } - } - } - // A shared indirect /MK is updated where it lives, not copied into each widget. - match indirect { - Some(r) => doc.set(r, Object::Dict(mk)), - None => doc.update_dict(w.obj, |d| d.set(b"MK".to_vec(), Object::Dict(mk)))?, - } - } - if touches_bs { - let indirect = wd.get(b"BS").and_then(Object::as_ref); - let mut bs = wd.get(b"BS").map(|b| doc.resolve(b)).and_then(|b| b.as_dict().cloned()).unwrap_or_default(); - if let Some(width) = patch.width { - bs.set(b"W".to_vec(), Object::Real(width)); - } - if let Some(style) = patch.style { - bs.set(b"S".to_vec(), Object::name(style.code())); - if style == BorderStyle::Dashed && !bs.contains(b"D") { - bs.set(b"D".to_vec(), Object::Array(vec![Object::Int(3)])); - } - } - match indirect { - Some(r) => doc.set(r, Object::Dict(bs)), - None => doc.update_dict(w.obj, |d| d.set(b"BS".to_vec(), Object::Dict(bs)))?, - } - } - Ok(()) -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum BorderStyle { - Solid, - Dashed, - Beveled, - Inset, - Underline, -} - -impl BorderStyle { - pub const ALL: [BorderStyle; 5] = [BorderStyle::Solid, BorderStyle::Dashed, BorderStyle::Beveled, BorderStyle::Inset, BorderStyle::Underline]; - - fn code(self) -> &'static str { - match self { - BorderStyle::Solid => "S", - BorderStyle::Dashed => "D", - BorderStyle::Beveled => "B", - BorderStyle::Inset => "I", - BorderStyle::Underline => "U", - } - } - - pub fn label(self) -> &'static str { - match self { - BorderStyle::Solid => "Solid", - BorderStyle::Dashed => "Dashed", - BorderStyle::Beveled => "Beveled", - BorderStyle::Inset => "Inset", - BorderStyle::Underline => "Underline", - } - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum FieldFont { - Helvetica, - Times, - Courier, -} - -impl FieldFont { - pub const ALL: [FieldFont; 3] = [FieldFont::Helvetica, FieldFont::Times, FieldFont::Courier]; - - fn resource(self) -> &'static str { - match self { - FieldFont::Helvetica => "Helv", - FieldFont::Times => "TiRo", - FieldFont::Courier => "Cour", - } - } - - pub fn label(self) -> &'static str { - match self { - FieldFont::Helvetica => "Helvetica", - FieldFont::Times => "Times Roman", - FieldFont::Courier => "Courier", - } - } -} - -/// The mark a check box or radio button shows when on (Field Properties ▸ Options ▸ style). -/// Stored as `/MK /CA`, the ZapfDingbats character Acrobat uses for it; PdfCraft draws the -/// mark as paths, so no symbol font is needed. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum CheckStyle { - Check, - Circle, - Cross, - Diamond, - Square, - Star, -} - -impl CheckStyle { - pub const ALL: [CheckStyle; 6] = - [CheckStyle::Check, CheckStyle::Circle, CheckStyle::Cross, CheckStyle::Diamond, CheckStyle::Square, CheckStyle::Star]; - - fn code(self) -> &'static str { - match self { - CheckStyle::Check => "4", - CheckStyle::Circle => "l", - CheckStyle::Cross => "8", - CheckStyle::Diamond => "u", - CheckStyle::Square => "n", - CheckStyle::Star => "H", - } - } - - pub fn label(self) -> &'static str { - match self { - CheckStyle::Check => "Check", - CheckStyle::Circle => "Circle", - CheckStyle::Cross => "Cross", - CheckStyle::Diamond => "Diamond", - CheckStyle::Square => "Square", - CheckStyle::Star => "Star", - } - } - - /// A widget's style: its `/MK /CA`, else a check for check boxes and a circle for radios. - pub(crate) fn of_widget(doc: &Document, wd: &Dict, kind: FieldKind) -> Self { - let mk = wd.get(b"MK").and_then(|m| doc.resolve(m).as_dict().cloned()).unwrap_or_default(); - let ca = mk.get(b"CA").and_then(|c| doc.resolve(c).as_string().map(|s| s.to_text())); - CheckStyle::ALL.into_iter().find(|s| ca.as_deref() == Some(s.code())).unwrap_or(if kind == FieldKind::Radio { - CheckStyle::Circle - } else { - CheckStyle::Check - }) - } -} - -/// A check box's or radio button's style (from its first widget). -pub fn check_style(doc: &Document, f: &Field) -> CheckStyle { - let wd = f.widgets.first().and_then(|w| doc.get(w.obj).as_dict().cloned()).unwrap_or_default(); - CheckStyle::of_widget(doc, &wd, f.kind) -} - -fn rgb_of(doc: &Document, o: Option<&Object>) -> Option<[f64; 3]> { - let v: Vec = doc.resolve(o?).as_array()?.iter().filter_map(Object::as_f64).collect(); - match v.len() { - 1 => Some([v[0]; 3]), - 3 => Some([v[0], v[1], v[2]]), - 4 => Some([(1.0 - v[0]) * (1.0 - v[3]), (1.0 - v[1]) * (1.0 - v[3]), (1.0 - v[2]) * (1.0 - v[3])]), - _ => None, - } -} - -/// A field's current look (from its first widget and its default appearance). -pub fn look(doc: &Document, f: &Field) -> Look { - let wd = f.widgets.first().and_then(|w| doc.get(w.obj).as_dict().cloned()).unwrap_or_default(); - let mk = wd.get(b"MK").and_then(|m| doc.resolve(m).as_dict().cloned()).unwrap_or_default(); - let bs = wd.get(b"BS").and_then(|b| doc.resolve(b).as_dict().cloned()).unwrap_or_default(); - let da = wd.get(b"DA").and_then(|o| doc.resolve(o).as_string().map(|s| s.to_text())).unwrap_or_else(|| f.da.clone()); - let parsed = appearance::parse_da(&da); - let nums: Vec = parsed.color.split_whitespace().filter_map(|t| t.parse().ok()).collect(); - let text = match nums.len() { - 1 => [nums[0]; 3], - 3 => [nums[0], nums[1], nums[2]], - 4 => [(1.0 - nums[0]) * (1.0 - nums[3]), (1.0 - nums[1]) * (1.0 - nums[3]), (1.0 - nums[2]) * (1.0 - nums[3])], - _ => [0.0; 3], - }; - Look { - border: rgb_of(doc, mk.get(b"BC")), - fill: rgb_of(doc, mk.get(b"BG")), - width: bs.get(b"W").and_then(|w| doc.resolve(w).as_f64()).unwrap_or(1.0), - style: match bs.name(b"S") { - Some(b"D") => BorderStyle::Dashed, - Some(b"B") => BorderStyle::Beveled, - Some(b"I") => BorderStyle::Inset, - Some(b"U") => BorderStyle::Underline, - _ => BorderStyle::Solid, - }, - text, - font: match parsed.font.as_str() { - "TiRo" | "Times-Roman" => FieldFont::Times, - "Cour" | "Courier" => FieldFont::Courier, - _ => FieldFont::Helvetica, - }, - } -} - -/// A `/DA` string with the given font, size and colour. -fn da_string(font: FieldFont, size: f64, c: [f64; 3]) -> String { - let f = crate::appearance::fmt; - format!("/{} {} Tf {} {} {} rg", font.resource(), f(size.clamp(0.0, 100.0)), f(c[0]), f(c[1]), f(c[2])) -} - -fn add_to_page(doc: &mut Document, page: ObjRef, widget: ObjRef) -> Result<(), FormError> { - let existing = doc.get(page).as_dict().and_then(|d| d.get(b"Annots").cloned()); - match existing.as_ref().and_then(|o| o.as_ref()).filter(|r| doc.get(*r).as_array().is_some()) { - Some(r) => { - let mut a = doc.get(r).as_array().cloned().unwrap_or_default(); - a.push(Object::Ref(widget)); - doc.set(r, Object::Array(a)); - } - None => { - let mut a = existing.and_then(|o| o.as_array().cloned()).unwrap_or_default(); - a.push(Object::Ref(widget)); - doc.update_dict(page, |d| d.set(b"Annots".to_vec(), Object::Array(a)))?; - } - } - Ok(()) -} - -fn add_to_fields(doc: &mut Document, af: ObjRef, field: ObjRef) -> Result<(), FormError> { - let mut d = doc.get(af).as_dict().cloned().unwrap_or_default(); - let mut list = d.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); - list.push(Object::Ref(field)); - d.set(b"Fields".to_vec(), Object::Array(list)); - doc.set(af, Object::Dict(d)); - Ok(()) -} - -/// The most parts a dotted field name may have ("a.b.c" has three). -const MAX_NAME_PARTS: usize = 32; - -/// The field attributes a field inherits from its parents (ISO 32000-2 §12.7.4.1). -const INHERITED_KEYS: [&[u8]; 7] = [b"FT", b"Ff", b"V", b"DV", b"DA", b"Q", b"MaxLen"]; - -/// The parts of a full field name: `a.b` is the field `b` under the parent field `a`, since a -/// field's own (partial) name can't contain a period (ISO 32000-2 §12.7.4.2) (#2696). -fn name_parts(name: &str) -> Result, FormError> { - let parts: Vec<&str> = name.split('.').take(MAX_NAME_PARTS.saturating_add(1)).collect(); - if parts.len() > MAX_NAME_PARTS { - return invalid(format!("field names can have at most {MAX_NAME_PARTS} parts")); - } - if parts.iter().any(|p| p.trim().is_empty()) { - return invalid("field names can't be empty, or have an empty part between periods"); - } - Ok(parts) -} - -/// Why the full name `name` can't be used beside the field named `other`, if it can't: the -/// same name, or one inside the other (a field that has a value can't also hold fields). -fn clash(name: &str, other: &str) -> Option { - if name == other { - return Some(format!("a field named {name:?} already exists")); - } - let inside = |a: &str, b: &str| a.strip_prefix(b).is_some_and(|rest| rest.starts_with('.')); - (inside(name, other) || inside(other, name)).then(|| format!("{name:?} can't be used beside the field {other:?}")) -} - -/// The array `key` of `holder` (`/Fields` of the form, `/Kids` of a field). -fn list_of(doc: &Document, holder: ObjRef, key: &[u8]) -> Vec { - let d = doc.get(holder).as_dict().cloned().unwrap_or_default(); - d.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default() -} - -/// Add `field` to `parent`'s `/Kids`, or to the form's `/Fields` at the top. -fn link(doc: &mut Document, af: ObjRef, parent: Option, field: ObjRef) -> Result<(), FormError> { - let Some(p) = parent else { return add_to_fields(doc, af, field) }; - let mut kids = list_of(doc, p, b"Kids"); - kids.push(Object::Ref(field)); - doc.update_dict(p, |d| d.set(b"Kids".to_vec(), Object::Array(kids)))?; - Ok(()) -} - -/// The node named `name` (its own `/T`) directly under `parent`, or at the top of the form. -fn child_named(doc: &Document, af: ObjRef, parent: Option, name: &str) -> Option { - let list = match parent { - Some(p) => list_of(doc, p, b"Kids"), - None => list_of(doc, af, b"Fields"), - }; - list.iter() - .filter_map(|o| o.as_ref()) - .find(|r| doc.get(*r).as_dict().and_then(|d| d.get(b"T").cloned()).and_then(|t| crate::text_of(&doc.resolve(&t))).as_deref() == Some(name)) -} - -/// The values a parent field passes to the fields under it that can't be turned off by giving -/// the field a neutral one of its own, as `/Ff` and `/Q` can. -const PASSED_VALUES: [&[u8]; 3] = [b"V", b"DV", b"MaxLen"]; - -/// Whether `field` may go in the field tree under the full name `parts`, changing nothing. The -/// field list holds only terminal fields, so a parent left empty by a move isn't in it: a node -/// of the same name there is found here. -/// `inherited` is what the field will hold as its own before it moves (see [`inherited`]). -fn check_place(doc: &Document, af: ObjRef, field: ObjRef, parts: &[&str], inherited: &Dict) -> Result<(), FormError> { - let Some((own, parents)) = parts.split_last() else { return invalid("field names can't be empty") }; - let mut owned = doc.get(field).as_dict().cloned().unwrap_or_default(); - for (k, v) in inherited.iter() { - owned.set(k.to_vec(), v.clone()); - } - let mut at: Option = None; - let mut whole_path = true; - for part in parents { - let Some(r) = child_named(doc, af, at, part) else { - whole_path = false; - break; - }; - // A value, reset value or length limit held by a parent would pass to this field. - let held = doc.get(r).as_dict().is_some_and(|d| PASSED_VALUES.iter().any(|k| d.contains(k) && !owned.contains(k))); - if held { - return invalid(format!("{part:?} holds a value for the fields under it, so {:?} can't be put there", parts.join("."))); - } - at = Some(r); - } - // A field being moved is still in the tree here, but under another full name (a move to its - // own name changes nothing and doesn't get here), so a node found is another. - if whole_path && child_named(doc, af, at, own).is_some() { - return invalid(format!("a field named {:?} already exists", parts.join("."))); - } - Ok(()) -} - -/// Put `field` in the field tree under the full name `parts`: its own `/T` is the last part, -/// and the parts before it name parent fields, reused where the tree has them and made where -/// not (Acrobat makes `a.b` a field `b` under a field `a`). -fn attach(doc: &mut Document, af: ObjRef, field: ObjRef, parts: &[&str]) -> Result<(), FormError> { - let Some((own, parents)) = parts.split_last() else { return invalid("field names can't be empty") }; - check_place(doc, af, field, parts, &Dict::new())?; - let mut parent: Option = None; - let mut inherits = false; - for part in parents { - let next = match child_named(doc, af, parent, part) { - Some(r) => { - inherits |= doc.get(r).as_dict().is_some_and(|d| d.contains(b"Ff") || d.contains(b"Q")); - r - } - None => { - let mut d = Dict::new(); - d.set(b"T".to_vec(), PdfString::text(part)); - d.set(b"Kids".to_vec(), Object::Array(Vec::new())); - if let Some(p) = parent { - d.set(b"Parent".to_vec(), Object::Ref(p)); - } - let r = doc.add(Object::Dict(d)); - link(doc, af, parent, r)?; - r - } - }; - parent = Some(next); - } - doc.update_dict(field, |d| { - d.set(b"T".to_vec(), PdfString::text(own)); - match parent { - Some(p) => d.set(b"Parent".to_vec(), Object::Ref(p)), - None => { - d.remove(b"Parent"); - } - } - // An existing parent's flags and alignment would otherwise pass to this field. - if inherits { - for k in [&b"Ff"[..], b"Q"] { - if !d.contains(k) { - d.set(k.to_vec(), Object::Int(0)); - } - } - } - })?; - link(doc, af, parent, field) -} - -/// What `field` inherits from its parents and doesn't hold itself, changing nothing. -fn inherited(doc: &Document, field: ObjRef) -> Dict { - let own = doc.get(field).as_dict().cloned().unwrap_or_default(); - let mut add = Dict::new(); - let mut seen = std::collections::HashSet::new(); - let mut parent = own.reference(b"Parent"); - while let Some(p) = parent { - if seen.len() > 64 || !seen.insert(p) { - break; - } - let Some(d) = doc.get(p).as_dict().cloned() else { break }; - for k in INHERITED_KEYS { - if let Some(v) = d.get(k).filter(|_| !own.contains(k) && !add.contains(k)) { - add.set(k.to_vec(), v.clone()); - } - } - parent = d.reference(b"Parent"); - } - add -} - -/// Copy onto `field` what it inherits (`add`, from [`inherited`]), so it keeps it when it moves. -fn own_inherited(doc: &mut Document, field: ObjRef, add: &Dict) -> Result<(), FormError> { - if add.iter().next().is_some() { - doc.update_dict(field, |d| { - for (k, v) in add.iter() { - d.set(k.to_vec(), v.clone()); - } - })?; - } - Ok(()) -} - -/// Take `field` out of the field tree: from its parent's `/Kids`, or from `/Fields`. -fn detach(doc: &mut Document, field: ObjRef) -> Result<(), FormError> { - let parent = doc.get(field).as_dict().and_then(|d| d.reference(b"Parent")); - let remove_from = |doc: &mut Document, holder: ObjRef, key: &[u8]| -> Result<(), FormError> { - let mut d = doc.get(holder).as_dict().cloned().unwrap_or_default(); - let list = d.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); - let kept: Vec = list.into_iter().filter(|o| o.as_ref() != Some(field)).collect(); - d.set(key.to_vec(), Object::Array(kept)); - doc.set(holder, Object::Dict(d)); - Ok(()) - }; - match parent { - Some(p) => remove_from(doc, p, b"Kids")?, - None => { - let root = doc.root().ok_or(FormError::NoForm)?; - match doc.get(root).as_dict().and_then(|d| d.get(b"AcroForm").cloned()) { - Some(Object::Ref(af)) => remove_from(doc, af, b"Fields")?, - // A form dictionary held directly in the catalog. - Some(Object::Dict(mut af)) => { - let list = af.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); - let kept: Vec = list.into_iter().filter(|o| o.as_ref() != Some(field)).collect(); - af.set(b"Fields".to_vec(), Object::Array(kept)); - doc.update_dict(root, |d| d.set(b"AcroForm".to_vec(), Object::Dict(af)))?; - } - _ => {} - } - } - } - Ok(()) -} - -/// The first free "N" name: not a field's, and not held by a node at the top of the field -/// tree (`held`), such as a parent field left empty by a move, which isn't in `existing`. -fn free_name(existing: &[Field], held: &[String], base: &str) -> String { - (1..=u64::MAX) - .map(|n| format!("{base}{n}")) - .find(|c| !held.contains(c) && !existing.iter().any(|f| f.name == *c || f.name.starts_with(&format!("{c}.")))) - .unwrap_or_else(|| base.to_string()) -} - -/// The own names (`/T`) of the nodes in the form's `/Fields`, changing nothing. -fn top_names(doc: &Document) -> Vec { - let Some(root) = doc.root() else { return Vec::new() }; - let Some(af) = doc.get(root).as_dict().and_then(|d| d.get(b"AcroForm").map(|o| doc.resolve(o))).and_then(|o| o.as_dict().cloned()) else { - return Vec::new(); - }; - let list = af.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); - list.iter().filter_map(|o| doc.resolve(o).as_dict().and_then(|d| d.get(b"T").cloned())).filter_map(|t| crate::text_of(&doc.resolve(&t))).collect() -} - -fn widget_dict(page: ObjRef, rect: [f64; 4]) -> Dict { - let mut d = Dict::new(); - d.set(b"Type".to_vec(), Object::name("Annot")); - d.set(b"Subtype".to_vec(), Object::name("Widget")); - d.set(b"Rect".to_vec(), Object::Array(rect.iter().map(|v| Object::Real(*v)).collect())); - d.set(b"P".to_vec(), Object::Ref(page)); - d.set(b"F".to_vec(), Object::Int(4)); - let mut mk = Dict::new(); - mk.set(b"BC".to_vec(), rgb([0.55, 0.55, 0.55])); - mk.set(b"BG".to_vec(), rgb([1.0, 1.0, 1.0])); - d.set(b"MK".to_vec(), Object::Dict(mk)); - let mut bs = Dict::new(); - bs.set(b"W".to_vec(), Object::Int(1)); - bs.set(b"S".to_vec(), Object::name("S")); - d.set(b"BS".to_vec(), Object::Dict(bs)); - d -} - -/// Add a field on `page` (0-based) in `rect` (user space). Returns its full name. -pub fn add_field(doc: &mut Document, page: usize, rect: [f64; 4], kind: &NewField, name: Option<&str>) -> Result { - let pages = page_refs(doc); - let page_ref = *pages.get(page).ok_or_else(|| FormError::Invalid(format!("page {} does not exist", page + 1)))?; - let rect = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])]; - if !rect.iter().all(|v| v.is_finite()) || rect[2] - rect[0] < MIN_FIELD_SIDE || rect[3] - rect[1] < MIN_FIELD_SIDE { - return invalid("the field is too small"); - } - let existing = fields(doc); - // A radio button names its group: a new group takes that name. - let name = match (name, kind) { - (None, NewField::Radio { group: Some(g), .. }) => Some(g.as_str()), - _ => name, - }; - let name = match name.map(str::trim) { - Some(n) if !n.is_empty() => n.to_string(), - _ => free_name(&existing, &top_names(doc), kind.base_name()), - }; - let parts = name_parts(&name)?; - let radio_group = match kind { - NewField::Radio { group, .. } => existing.iter().find(|f| f.kind == FieldKind::Radio && Some(&f.name) == group.as_ref()).cloned(), - _ => None, - }; - if radio_group.is_none() - && let Some(why) = existing.iter().find_map(|f| clash(&name, &f.name)) - { - return invalid(why); - } - let af = ensure_form(doc)?; - let mut w = widget_dict(page_ref, rect); - let field_name = match kind { - NewField::Radio { group: Some(g), .. } if radio_group.is_some() => g.clone(), - _ => name.clone(), - }; - match kind { - NewField::Text { multiline } => { - w.set(b"FT".to_vec(), Object::name("Tx")); - // Auto size (0): a fixed 12 pt is clipped in a box shorter than that (#1888). - w.set(b"DA".to_vec(), PdfString::literal(b"/Helv 0 Tf 0 g".to_vec())); - if *multiline { - w.set(b"Ff".to_vec(), Object::Int(flags::MULTILINE as i64)); - } - } - NewField::Date => { - w.set(b"FT".to_vec(), Object::name("Tx")); - w.set(b"DA".to_vec(), PdfString::literal(b"/Helv 0 Tf 0 g".to_vec())); - // Acrobat's format and keystroke actions for mm/dd/yyyy (run by its JavaScript engine). - let js = |s: &str| { - let mut a = Dict::new(); - a.set(b"S".to_vec(), Object::name("JavaScript")); - a.set(b"JS".to_vec(), PdfString::literal(s.as_bytes().to_vec())); - Object::Dict(a) - }; - let mut aa = Dict::new(); - aa.set(b"F".to_vec(), js("AFDate_FormatEx(\"mm/dd/yyyy\");")); - aa.set(b"K".to_vec(), js("AFDate_KeystrokeEx(\"mm/dd/yyyy\");")); - w.set(b"AA".to_vec(), Object::Dict(aa)); - } - NewField::CheckBox => { - w.set(b"FT".to_vec(), Object::name("Btn")); - w.set(b"V".to_vec(), Object::name("Off")); - w.set(b"AS".to_vec(), Object::name("Off")); - w.set(b"DA".to_vec(), PdfString::literal(b"/ZaDb 0 Tf 0 g".to_vec())); - } - NewField::Radio { export, .. } => { - w.set(b"AS".to_vec(), Object::name("Off")); - if export.trim().is_empty() || export == "Off" { - return invalid("a radio button needs an export value other than Off"); - } - } - NewField::Combo { options, editable } | NewField::List { options, multi: editable } => { - w.set(b"FT".to_vec(), Object::name("Ch")); - w.set(b"DA".to_vec(), PdfString::literal(b"/Helv 12 Tf 0 g".to_vec())); - let combo = matches!(kind, NewField::Combo { .. }); - let mut ff = 0; - if combo { - ff |= flags::COMBO; - if *editable { - ff |= flags::EDIT; - } - } else if *editable { - ff |= flags::MULTI_SELECT; - } - w.set(b"Ff".to_vec(), Object::Int(ff as i64)); - w.set(b"Opt".to_vec(), Object::Array(options.iter().map(|o| Object::String(PdfString::text(o))).collect())); - } - NewField::Button { caption } => { - w.set(b"FT".to_vec(), Object::name("Btn")); - w.set(b"Ff".to_vec(), Object::Int(flags::PUSH_BUTTON as i64)); - w.set(b"DA".to_vec(), PdfString::literal(b"/Helv 0 Tf 0 g".to_vec())); - let mut mk = w.get(b"MK").and_then(|m| m.as_dict()).cloned().unwrap_or_default(); - mk.set(b"BG".to_vec(), rgb([0.86, 0.86, 0.86])); - mk.set(b"CA".to_vec(), PdfString::text(caption)); - w.set(b"MK".to_vec(), Object::Dict(mk)); - } - NewField::Image => { - w.set(b"FT".to_vec(), Object::name("Btn")); - w.set(b"Ff".to_vec(), Object::Int(flags::PUSH_BUTTON as i64)); - w.set(b"DA".to_vec(), PdfString::literal(b"/Helv 0 Tf 0 g".to_vec())); - let mut mk = w.get(b"MK").and_then(|m| m.as_dict()).cloned().unwrap_or_default(); - // Icon only, no border or fill: the picture is the field. - mk.remove(b"BC"); - mk.remove(b"BG"); - mk.set(b"TP".to_vec(), Object::Int(1)); - w.set(b"MK".to_vec(), Object::Dict(mk)); - let mut a = Dict::new(); - a.set(b"S".to_vec(), Object::name("JavaScript")); - a.set(b"JS".to_vec(), PdfString::literal(b"event.target.buttonImportIcon();".to_vec())); - w.set(b"A".to_vec(), Object::Dict(a)); - } - NewField::Signature => { - w.set(b"FT".to_vec(), Object::name("Sig")); - } - } - let widget = doc.add(Object::Dict(w)); - match kind { - NewField::Radio { export, .. } => { - let group = match &radio_group { - Some(g) => g.obj, - None => { - let mut g = Dict::new(); - g.set(b"FT".to_vec(), Object::name("Btn")); - g.set(b"Ff".to_vec(), Object::Int((flags::RADIO | flags::NO_TOGGLE_TO_OFF) as i64)); - g.set(b"V".to_vec(), Object::name("Off")); - g.set(b"DA".to_vec(), PdfString::literal(b"/ZaDb 0 Tf 0 g".to_vec())); - g.set(b"Kids".to_vec(), Object::Array(Vec::new())); - let g = doc.add(Object::Dict(g)); - attach(doc, af, g, &parts)?; - g - } - }; - doc.update_dict(widget, |d| d.set(b"Parent".to_vec(), Object::Ref(group)))?; - doc.update_dict(group, |d| { - let mut kids = d.get(b"Kids").and_then(|k| k.as_array().cloned()).unwrap_or_default(); - kids.push(Object::Ref(widget)); - d.set(b"Kids".to_vec(), Object::Array(kids)); - })?; - let w = Widget { - obj: widget, - page: Some(page), - rect, - on_state: Some(export.clone()), - state: Some("Off".into()), - tab: usize::MAX, - locked: false, - hidden: false, - read_only: false, - rotation: 0, - caption: None, - list: None, - round: true, - }; - let ap = appearance::check_box_states(doc, &w, FieldKind::Radio, export, "/ZaDb 0 Tf 0 g"); - doc.update_dict(widget, |d| d.set(b"AP".to_vec(), Object::Dict(ap)))?; - } - _ => attach(doc, af, widget, &parts)?, - } - add_to_page(doc, page_ref, widget)?; - redraw_field(doc, &field_name)?; - Ok(field_name) -} - -/// Regenerate every widget appearance of a field from its current value and settings. A value or -/// button caption its font can't hold is drawn with `?` for those characters, the widget is marked -/// (`pdfcraft_fonts::UNDRAWN_MARK`) and other viewers are asked to redraw the form (see -/// `ask_viewers_to_redraw`), as when a value is set. -pub fn redraw_field(doc: &mut Document, name: &str) -> Result<(), FormError> { - redraw_widgets(doc, name, None) -} - -/// `redraw_field` for the widget at index `only` alone (so a caption-only edit leaves the field's -/// other widgets' appearances as they are), or for every widget when `only` is `None`. Fails with -/// `FormError::NoSuchField` when no field is named `name`. -fn redraw_widgets(doc: &mut Document, name: &str, only: Option) -> Result<(), FormError> { - let f = fields(doc).into_iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?; - let mut undrawn = false; - for (index, w) in f.widgets.iter().enumerate() { - if only.is_some_and(|only| only != index) { - continue; - } - let mut missing = None; - let ap = match f.kind { - FieldKind::Text | FieldKind::Combo | FieldKind::List => { - let (s, m) = appearance::field_appearance_drawn(doc, &f, w, &f.value, true); - missing = m; - let r = doc.add(Object::Stream(s)); - let mut d = Dict::new(); - d.set(b"N".to_vec(), Object::Ref(r)); - d - } - FieldKind::CheckBox | FieldKind::Radio => { - let on = w.on_state.clone().unwrap_or_else(|| "Yes".into()); - appearance::check_box_states(doc, w, f.kind, &on, &f.da) - } - FieldKind::PushButton => { - let (s, m) = button_appearance(doc, w, &f.da); - missing = m; - let r = doc.add(Object::Stream(s)); - let mut d = Dict::new(); - d.set(b"N".to_vec(), Object::Ref(r)); - d - } - FieldKind::Signature => { - let s = empty_box(doc, w); - let r = doc.add(Object::Stream(s)); - let mut d = Dict::new(); - d.set(b"N".to_vec(), Object::Ref(r)); - d - } - }; - undrawn |= missing.is_some(); - let existing = crate::merged_appearance(doc, w.obj, &ap); - doc.update_dict(w.obj, |d| { - d.set(b"AP".to_vec(), Object::Dict(existing)); - pdfcraft_fonts::set_undrawn_mark(d, missing); - })?; - } - if undrawn { - crate::ask_viewers_to_redraw(doc)?; - } - Ok(()) -} - -fn frame_only(doc: &Document, w: &Widget) -> (String, crate::appearance::Placement) { - let (page_w, page_h) = ((w.rect[2] - w.rect[0]).max(1.0), (w.rect[3] - w.rect[1]).max(1.0)); - let wobj = doc.get(w.obj); - let wd = wobj.as_dict().cloned().unwrap_or_default(); - let place = crate::appearance::placement(crate::appearance::mk_rotation(doc, &wd), page_w, page_h); - let (width, height) = (place.layout_w, place.layout_h); - let mk = wd.get(b"MK").and_then(|m| m.as_dict()).cloned().unwrap_or_default(); - let col = |k: &[u8]| -> Option { - let v: Vec = mk.get(k)?.as_array()?.iter().filter_map(|x| x.as_f64()).collect(); - (v.len() == 3).then(|| format!("{} {} {}", crate::appearance::fmt(v[0]), crate::appearance::fmt(v[1]), crate::appearance::fmt(v[2]))) - }; - let mut c = String::new(); - if let Some(bg) = col(b"BG") { - c.push_str(&format!("{bg} rg 0 0 {width:.3} {height:.3} re f\n")); - } - if let Some(bc) = col(b"BC") { - c.push_str(&format!("{bc} RG 1 w 0.5 0.5 {:.3} {:.3} re S\n", width - 1.0, height - 1.0)); - } - (c, place) -} - -fn form_stream(place: crate::appearance::Placement, content: Vec, resources: Dict) -> Stream { - let mut d = Dict::new(); - d.set(b"Type".to_vec(), Object::name("XObject")); - d.set(b"Subtype".to_vec(), Object::name("Form")); - crate::appearance::set_form_box(&mut d, place); - d.set(b"Resources".to_vec(), Object::Dict(resources)); - Stream::flate(d, &content) -} - -/// A push button: background, border, its icon (`/MK /I`, scaled to fit and centred) and its -/// centred caption (unless the layout is icon only, `/TP 1`). The caption uses the font, size and -/// color its `/DA` asks for (the widget's, else `field_da`, the field's own or inherited one). -/// Also returns the first caption character the font can't draw (drawn as `?`), for the caller to -/// mark and report. -pub(crate) fn button_appearance(doc: &Document, w: &Widget, field_da: &str) -> (Stream, Option) { - let (mut c, place) = frame_only(doc, w); - let (width, height) = (place.layout_w, place.layout_h); - let wobj = doc.get(w.obj); - let mk = wobj.as_dict().and_then(|d| d.get(b"MK")).map(|m| doc.resolve(m)).and_then(|m| m.as_dict().cloned()).unwrap_or_default(); - let caption = mk.get(b"CA").map(|c| doc.resolve(c)).and_then(|c| c.as_string().map(|s| s.to_text())).unwrap_or_default(); - let icon_only = mk.get(b"TP").and_then(|t| doc.resolve(t).as_int()) == Some(1); - let mut content = std::mem::take(&mut c).into_bytes(); - let mut xobjects = Dict::new(); - if let Some(icon) = mk.get(b"I").and_then(Object::as_ref) - && let Object::Stream(s) = &*doc.get(icon) - { - let bb: Vec = s.dict.get(b"BBox").and_then(|b| b.as_array().map(|a| a.iter().filter_map(Object::as_f64).collect())).unwrap_or_default(); - if let [x0, y0, x1, y1] = bb[..] - && x1 > x0 - && y1 > y0 - { - // Proportional, as large as fits (Acrobat's default icon placement), centred. - let k = ((width - 2.0) / (x1 - x0)).min((height - 2.0) / (y1 - y0)).max(0.0); - let (dx, dy) = ((width - (x1 - x0) * k) / 2.0 - x0 * k, (height - (y1 - y0) * k) / 2.0 - y0 * k); - content.extend(format!("q {k:.6} 0 0 {k:.6} {dx:.3} {dy:.3} cm /Icon Do Q\n").bytes()); - xobjects.set(b"Icon".to_vec(), Object::Ref(icon)); - } - } - let mut missing = None; - let mut fonts = Dict::new(); - if !icon_only && !caption.is_empty() { - let da = wobj.as_dict().and_then(|d| d.get(b"DA")).and_then(|o| doc.resolve(o).as_string().map(|s| s.to_text())); - let auto = ((height - 4.0) * 0.6).clamp(4.0, 14.0); - let (ops, name, font, m) = appearance::button_caption(doc, da.as_deref().unwrap_or(field_da), &caption, width, height, auto); - content.extend(ops); - fonts.set(name.into_bytes(), font); - missing = m; - } - let mut res = Dict::new(); - res.set(b"Font".to_vec(), Object::Dict(fonts)); - if !xobjects.is_empty() { - res.set(b"XObject".to_vec(), Object::Dict(xobjects)); - } - (form_stream(place, content, res), missing) -} - -/// Give a push button (an image field) the picture `image` (an image XObject of `px` pixels): -/// it becomes the button's icon (`/MK /I`) and its appearance. -pub fn set_button_icon(doc: &mut Document, name: &str, image: ObjRef, px: (u32, u32)) -> Result<(), FormError> { - let f = fields(doc).into_iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?; - if f.kind != FieldKind::PushButton { - return invalid(format!("{name} is not a button or image field")); - } - // The icon form draws the image in a box of its pixel size (keeping its aspect ratio). - let (w, h) = (px.0.max(1) as f64, px.1.max(1) as f64); - let mut xo = Dict::new(); - xo.set(b"Im0".to_vec(), Object::Ref(image)); - let mut res = Dict::new(); - res.set(b"XObject".to_vec(), Object::Dict(xo)); - let icon = - doc.add(Object::Stream(form_stream(crate::appearance::placement(0, w, h), format!("q {w} 0 0 {h} 0 0 cm /Im0 Do Q").into_bytes(), res))); - for wd in &f.widgets { - let mut mk = - doc.get(wd.obj).as_dict().and_then(|d| d.get(b"MK").map(|m| doc.resolve(m))).and_then(|m| m.as_dict().cloned()).unwrap_or_default(); - mk.set(b"I".to_vec(), Object::Ref(icon)); - if !mk.contains(b"TP") { - mk.set(b"TP".to_vec(), Object::Int(1)); - } - doc.update_dict(wd.obj, |d| d.set(b"MK".to_vec(), Object::Dict(mk)))?; - } - redraw_field(doc, name) -} - -fn empty_box(doc: &Document, w: &Widget) -> Stream { - let (c, place) = frame_only(doc, w); - form_stream(place, c.into_bytes(), Dict::new()) -} - -/// Swap width and height, keeping the center. `None` when that is not a usable widget. -fn swapped_around_center(r: [f64; 4]) -> Option<[f64; 4]> { - if !r.iter().all(|v| v.is_finite()) { - return None; - } - let (x0, y0, x1, y1) = (r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])); - let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0); - let (nw, nh) = (y1 - y0, x1 - x0); - let out = [cx - nw / 2.0, cy - nh / 2.0, cx + nw / 2.0, cy + nh / 2.0]; - (out.iter().all(|v| v.is_finite()) && out[2] - out[0] >= MIN_FIELD_SIDE && out[3] - out[1] >= MIN_FIELD_SIDE).then_some(out) -} - -/// Change a field's properties (General and Options tabs) and redraw it. -pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result { - if props.font_size.is_some_and(|size| !size.is_finite() || size < 0.0) { - return invalid("font size must be a finite, non-negative number"); - } - if let Some(patch) = &props.appearance { - if props.look.is_some() { - return invalid("give an appearance patch or a complete look, not both"); - } - patch.validate()?; - } - let all = fields(doc); - let f = all.iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?.clone(); - // Refuse a wrong type/index before any other requested property is written. - if let Some((wi, _)) = &props.caption { - if f.kind != FieldKind::PushButton { - return invalid("only push buttons have a caption"); - } - if f.widgets.get(*wi).is_none() { - return invalid(format!("{name} has no widget {}", wi.saturating_add(1))); - } - } - // Validated before anything is written, so a refused rotation leaves the document unchanged. - // `Some(Some(rect))`: the widget's axis changes and it gets the swapped rectangle. - let mut rotated_rect: Option> = None; - if let Some((wi, rot)) = props.rotation { - if !matches!(rot, 0 | 90 | 180 | 270) { - return invalid("rotation must be 0, 90, 180 or 270 degrees"); - } - let Some(w) = f.widgets.get(wi) else { - return invalid(format!("{name} has no widget {}", wi.saturating_add(1))); - }; - let axis_change = (w.rotation % 180 == 0) != (rot % 180 == 0); - rotated_rect = Some(None); - if axis_change && props.rect.is_none() { - let Some(rect) = swapped_around_center(w.rect) else { return invalid("the field is too small") }; - rotated_rect = Some(Some(rect)); - } - } - // A locked field only takes unlocking (Acrobat greys its properties out). - if f.locked() && props.locked != Some(false) && *props != (FieldProps { locked: props.locked, ..FieldProps::default() }) { - return invalid(format!("{name} is locked: unlock it first")); - } - if let Some(lock) = props.locked { - for w in &f.widgets { - let flags = doc.get(w.obj).as_dict().and_then(|d| d.get(b"F").and_then(|v| doc.resolve(v).as_int())).unwrap_or(0); - let v = if lock { flags | 128 } else { flags & !128 }; - doc.update_dict(w.obj, |d| d.set(b"F".to_vec(), Object::Int(v)))?; - } - if *props == (FieldProps { locked: props.locked, ..FieldProps::default() }) { - return Ok(name.to_string()); - } - } - if let Some(acts) = &props.actions { - crate::set_field_actions(doc, name, acts)?; - } - let mut new_name = name.to_string(); - if let Some(n) = props.name.as_deref().map(str::trim).filter(|n| *n != f.name) { - // A full name under the same parents only renames the field's own part (the editor - // shows nested fields by their full name): it isn't moved, so it keeps its place and - // goes on inheriting from its parents. - let n = match (n.rsplit_once('.'), f.name.rsplit_once('.')) { - (Some((parents, own)), Some((current, _))) if parents == current => own, - _ => n, - }; - if n.contains('.') { - // A full name: the field moves under the parent fields it names (#2696). - let parts = name_parts(n)?; - if let Some(why) = all.iter().filter(|x| x.obj != f.obj).find_map(|x| clash(n, &x.name)) { - return invalid(why); - } - let af = ensure_form(doc)?; - // Checked before anything changes, so a refused move leaves the field as it was. - let add = inherited(doc, f.obj); - check_place(doc, af, f.obj, &parts, &add)?; - // From here a failure (a write that `check_place` couldn't foresee) would leave the - // field half moved in `doc`. The engine runs every edit on a copy of the document and - // keeps it only when the edit succeeds (`Session::apply`), so the open document never - // sees that. - own_inherited(doc, f.obj, &add)?; - detach(doc, f.obj)?; - attach(doc, af, f.obj, &parts)?; - new_name = n.to_string(); - } else { - if n.is_empty() { - return invalid("field names can't be empty"); - } - // Rename the terminal part (the field's own /T). The parents' part stays, so the name - // that must be free is the full one: `MASTER.a` renamed to `b` takes `MASTER.b` (ISO - // 32000-2 §12.7.4.2). The field itself is no clash when it keeps its own name. - let prefix = f.name.rsplit_once('.').map(|(p, _)| format!("{p}.")).unwrap_or_default(); - new_name = format!("{prefix}{n}"); - if let Some(why) = all.iter().filter(|x| x.obj != f.obj).find_map(|x| clash(&new_name, &x.name)) { - return invalid(why); - } - // A node of that name beside it that isn't a terminal field, such as a parent - // field left empty by a move. - let af = ensure_form(doc)?; - let parent = doc.get(f.obj).as_dict().and_then(|d| d.reference(b"Parent")); - if child_named(doc, af, parent, n).is_some_and(|r| r != f.obj) { - return invalid(format!("a field named {new_name:?} already exists")); - } - doc.update_dict(f.obj, |d| d.set(b"T".to_vec(), PdfString::text(n)))?; - } - // Calculations read the field by its new name, unless another field keeps the old one. - if all.iter().filter(|x| x.name == f.name).count() == 1 { - rename_in_calculations(doc, &all, &f.name, &new_name)?; - } - } - // The new default appearance when the size or the look changes. - let size = props.font_size.unwrap_or_else(|| crate::appearance::parse_da(&f.da).size); - let new_da = match &props.look { - Some(l) => da_string(l.font, size, l.text), - None => patch_da(&f.da, props.font_size, &props.appearance.unwrap_or_default()), - }; - if props.look.is_some() || props.appearance.is_some_and(|p| p.font.is_some()) { - ensure_form(doc)?; - } - let mut ff = f.flags; - let mut set_flag = |flag: u32, on: Option| { - if let Some(on) = on { - if on { - ff |= flag; - } else { - ff &= !flag; - } - } - }; - set_flag(flags::READ_ONLY, props.read_only); - set_flag(flags::REQUIRED, props.required); - if f.kind == FieldKind::Text { - set_flag(flags::MULTILINE, props.multiline); - } - for (flag, on) in &props.flags { - set_flag(*flag, Some(*on)); - } - // A comb spreads a fixed number of characters across the field: it needs a limit and - // excludes multi-line, password, file selection and scrolling (ISO 32000-2 Table 229). - if f.kind == FieldKind::Text && ff & flags::COMB != 0 { - let limit = match props.max_len { - Some(m) => m, - None => f.max_len, - }; - if limit.is_none_or(|m| m == 0) { - return invalid("a comb of characters needs a character limit"); - } - ff &= !(flags::MULTILINE | flags::PASSWORD | flags::FILE_SELECT); - } - if let Some(q) = props.quadding - && !(0..=2).contains(&q) - { - return invalid("alignment is 0 (left), 1 (centre) or 2 (right)"); - } - if props.options.as_ref().is_some_and(Vec::is_empty) && matches!(f.kind, FieldKind::Combo | FieldKind::List) { - return invalid("a list needs at least one option"); - } - if props.check_style.is_some() && !matches!(f.kind, FieldKind::CheckBox | FieldKind::Radio) { - return invalid("only check boxes and radio buttons have a check style"); - } - doc.update_dict(f.obj, |d| { - d.set(b"Ff".to_vec(), Object::Int(ff as i64)); - if let Some(t) = &props.tooltip { - if t.is_empty() { - d.remove(b"TU"); - } else { - d.set(b"TU".to_vec(), PdfString::text(t)); - } - } - if let Some(m) = props.max_len { - match m { - Some(m) => d.set(b"MaxLen".to_vec(), Object::Int(m as i64)), - None => { - d.remove(b"MaxLen"); - } - } - } - if let Some(opts) = &props.options { - d.set(b"Opt".to_vec(), Object::Array(opts.iter().map(|o| Object::String(PdfString::text(o))).collect())); - } - // "Sort items": the list itself is kept in order (export values stay with their items). - if ff & flags::SORT != 0 && matches!(f.kind, FieldKind::Combo | FieldKind::List) { - let mut items: Vec<(String, String)> = match &props.options { - Some(o) => o.iter().map(|x| (x.clone(), x.clone())).collect(), - None => f.options.clone(), - }; - items.sort_by_key(|(_, display)| display.to_lowercase()); - let opt = items - .iter() - .map(|(e, disp)| { - if e == disp { - Object::String(PdfString::text(disp)) - } else { - Object::Array(vec![Object::String(PdfString::text(e)), Object::String(PdfString::text(disp))]) - } - }) - .collect(); - d.set(b"Opt".to_vec(), Object::Array(opt)); - } - if let Some(q) = props.quadding { - d.set(b"Q".to_vec(), Object::Int(q)); - } - match &props.default_value { - // Buttons name their default state; text and choices hold a string. - Some(Some(v)) if matches!(f.kind, FieldKind::CheckBox | FieldKind::Radio) => d.set(b"DV".to_vec(), Object::Name(crate::name_bytes(v))), - Some(Some(v)) => d.set(b"DV".to_vec(), PdfString::text(v)), - Some(None) => { - d.remove(b"DV"); - } - None => {} - } - if props.font_size.is_some() || props.look.is_some() || props.appearance.is_some_and(|p| p.font.is_some() || p.text.is_some()) { - d.set(b"DA".to_vec(), PdfString::literal(new_da.clone().into_bytes())); - } - })?; - if props.appearance.is_some() || (props.font_size.is_some() && props.look.is_none()) { - let patch = &props.appearance.unwrap_or_default(); - for w in &f.widgets { - patch_widget_look(doc, w, patch)?; - // A widget's own /DA overrides its field's /DA. Patch that override separately - // rather than replacing its font or colour with the first widget's defaults. - if w.obj != f.obj && (props.font_size.is_some() || patch.font.is_some() || patch.text.is_some()) { - let da = doc - .get(w.obj) - .as_dict() - .and_then(|d| d.get(b"DA").cloned()) - .map(|v| doc.resolve(&v)) - .and_then(|v| v.as_string().map(|s| s.to_text())); - if let Some(da) = da { - let da = patch_da(&da, props.font_size, patch); - doc.update_dict(w.obj, |d| d.set(b"DA".to_vec(), PdfString::literal(da.into_bytes())))?; - } - } - } - } - if let Some(l) = &props.look { - let arr = |c: [f64; 3]| Object::Array(c.iter().map(|v| Object::Real(v.clamp(0.0, 1.0))).collect()); - for w in &f.widgets { - // Resolved first: an indirect /MK keeps its mark, rotation and other entries. - let mut mk = doc.get(w.obj).as_dict().and_then(|d| d.get(b"MK").map(|m| doc.resolve(m).as_dict().cloned())).flatten().unwrap_or_default(); - doc.update_dict(w.obj, |d| { - match l.border { - Some(c) => mk.set(b"BC".to_vec(), arr(c)), - None => { - mk.remove(b"BC"); - } - } - match l.fill { - Some(c) => mk.set(b"BG".to_vec(), arr(c)), - None => { - mk.remove(b"BG"); - } - } - d.set(b"MK".to_vec(), Object::Dict(mk)); - let mut bs = Dict::new(); - bs.set(b"W".to_vec(), Object::Real(l.width.clamp(0.0, 12.0))); - bs.set(b"S".to_vec(), Object::name(l.style.code())); - if l.style == BorderStyle::Dashed { - bs.set(b"D".to_vec(), Object::Array(vec![Object::Int(3)])); - } - d.set(b"BS".to_vec(), Object::Dict(bs)); - })?; - } - } - if let Some(style) = props.check_style { - for w in &f.widgets { - // Resolved first: an indirect /MK keeps its colours. - let mut mk = doc.get(w.obj).as_dict().and_then(|d| d.get(b"MK").map(|m| doc.resolve(m).as_dict().cloned())).flatten().unwrap_or_default(); - mk.set(b"CA".to_vec(), PdfString::text(style.code())); - doc.update_dict(w.obj, |d| d.set(b"MK".to_vec(), Object::Dict(mk)))?; - } - } - if let Some((wi, r)) = props.rect { - let w = f.widgets.get(wi).ok_or_else(|| FormError::Invalid(format!("{name} has no widget {}", wi + 1)))?; - let r = [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])]; - if !r.iter().all(|v| v.is_finite()) || r[2] - r[0] < MIN_FIELD_SIDE || r[3] - r[1] < MIN_FIELD_SIDE { - return invalid("the field is too small"); - } - doc.update_dict(w.obj, |d| d.set(b"Rect".to_vec(), Object::Array(r.iter().map(|v| Object::Real(*v)).collect())))?; - } - if props.format.is_some() || props.validate.is_some() || props.calculate.is_some() { - if !matches!(f.kind, FieldKind::Text | FieldKind::Combo) { - return invalid("only text fields and drop-down lists have format, validate and calculate scripts"); - } - let mut aa = doc - .get(f.obj) - .as_dict() - .and_then(|d| d.get(b"AA").cloned()) - .map(|a| doc.resolve(&a)) - .and_then(|a| a.as_dict().cloned()) - .unwrap_or_default(); - let action = |js: &str| -> Object { - let mut a = Dict::new(); - a.set(b"S".to_vec(), Object::name("JavaScript")); - a.set(b"JS".to_vec(), PdfString::literal(js.as_bytes().to_vec())); - Object::Dict(a) - }; - if let Some(fm) = &props.format { - aa.remove(b"F"); - aa.remove(b"K"); - if let Some((fjs, kjs)) = crate::af::format_js(fm) { - if !fjs.is_empty() { - aa.set(b"F".to_vec(), action(&fjs)); - } - aa.set(b"K".to_vec(), action(&kjs)); - } - } - if let Some(v) = &props.validate { - aa.remove(b"V"); - if let Some(js) = crate::af::validate_js(v) { - aa.set(b"V".to_vec(), action(&js)); - } - } - if let Some(c) = &props.calculate { - aa.remove(b"C"); - if let Some(js) = crate::af::calculate_js(c) { - aa.set(b"C".to_vec(), action(&js)); - } - crate::calc_order::keep_listed(doc, f.obj, *c != crate::af::Calculate::None)?; - } - doc.update_dict(f.obj, |d| { - if aa.is_empty() { - d.remove(b"AA"); - } else { - d.set(b"AA".to_vec(), Object::Dict(aa)); - } - })?; - } - // A complete look replaces widget /DA overrides. Partial changes were patched above - // against each override, including size-only changes that keep its font and colour. - if props.look.is_some() { - for w in &f.widgets { - if w.obj != f.obj { - doc.update_dict(w.obj, |d| { - if d.contains(b"DA") { - d.set(b"DA".to_vec(), PdfString::literal(new_da.clone().into_bytes())); - } - })?; - } - } - } - if let (Some((wi, rot)), Some(swapped)) = (props.rotation, rotated_rect) - && let Some(w) = f.widgets.get(wi) - { - if let Some(rect) = swapped { - doc.update_dict(w.obj, |d| d.set(b"Rect".to_vec(), Object::Array(rect.iter().map(|v| Object::Real(*v)).collect())))?; - } - let mut mk = doc.get(w.obj).as_dict().and_then(|d| d.get(b"MK").map(|m| doc.resolve(m).as_dict().cloned())).flatten().unwrap_or_default(); - if rot == 0 { - mk.remove(b"R"); - } else { - mk.set(b"R".to_vec(), Object::Int(rot)); - } - doc.update_dict(w.obj, |d| { - if mk.is_empty() { - d.remove(b"MK"); - } else { - d.set(b"MK".to_vec(), Object::Dict(mk)); - } - })?; - } - if let Some((wi, caption)) = &props.caption - && let Some(w) = f.widgets.get(*wi) - { - // Copy an indirect/shared MK before patching it; colours, icons, rotation and - // unknown entries belong to this widget, and other owners keep their old label. - let mut mk = doc.get(w.obj).as_dict().and_then(|d| d.get(b"MK").map(|m| doc.resolve(m).as_dict().cloned())).flatten().unwrap_or_default(); - mk.set(b"CA".to_vec(), PdfString::text(caption)); - doc.update_dict(w.obj, |d| d.set(b"MK".to_vec(), Object::Dict(mk)))?; - } - // A label-only edit must not replace another widget's custom appearances. - let only = props.caption.as_ref().filter(|_| *props == (FieldProps { caption: props.caption.clone(), ..Default::default() })).map(|(wi, _)| *wi); - redraw_widgets(doc, &new_name, only)?; - if props.calculate.is_some() { - crate::recalculate(doc)?; - } - Ok(new_name) -} - -/// Name field `old` `new` in each field's Calculate script that reads it -/// ([`crate::af::rename_in_calculation`]). -fn rename_in_calculations(doc: &mut Document, all: &[Field], old: &str, new: &str) -> Result<(), FormError> { - let aa_of = - |doc: &Document, r: ObjRef| doc.get(r).as_dict().and_then(|d| d.get(b"AA").map(|a| doc.resolve(a))).and_then(|a| a.as_dict().cloned()); - for f in all { - // The script is the field's, or its only widget's when the field has none, as - // `actions_of` reads it. - let only_widget = || match f.widgets.as_slice() { - [w] => aa_of(doc, w.obj).map(|aa| (w.obj, aa)), - _ => None, - }; - let Some((owner, mut aa)) = aa_of(doc, f.obj).map(|aa| (f.obj, aa)).or_else(only_widget) else { continue }; - let Some(mut action) = aa.get(b"C").and_then(|a| doc.resolve(a).as_dict().cloned()) else { continue }; - let js = match action.get(b"JS").map(|js| doc.resolve(js)).as_deref() { - Some(Object::String(s)) => s.to_text(), - // A stream that isn't UTF-8 reads with replacement characters, which must not be - // written back in place of what it holds. - Some(Object::Stream(s)) => match s.decoded().ok().and_then(|b| String::from_utf8(b).ok()) { - Some(js) => js, - None => continue, - }, - _ => continue, - }; - let Some(js) = crate::af::rename_in_calculation(&js, old, new) else { continue }; - action.set(b"JS".to_vec(), PdfString::text(&js)); - aa.set(b"C".to_vec(), Object::Dict(action)); - doc.update_dict(owner, |d| d.set(b"AA".to_vec(), Object::Dict(aa)))?; - } - Ok(()) -} - -/// Delete a field: its widgets leave their pages and the field leaves the form. -pub fn delete_field(doc: &mut Document, name: &str) -> Result<(), FormError> { - let f = fields(doc).into_iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?; - delete_found_field(doc, f) -} - -/// [`delete_field`] for the field object `obj`, when separate fields share one name. -pub fn delete_field_object(doc: &mut Document, obj: ObjRef) -> Result<(), FormError> { - let f = fields(doc).into_iter().find(|f| f.obj == obj).ok_or_else(|| FormError::NoSuchField(format!("{obj:?}")))?; - delete_found_field(doc, f) -} - -fn delete_found_field(doc: &mut Document, f: Field) -> Result<(), FormError> { - let widgets: std::collections::HashSet = f.widgets.iter().map(|w| w.obj).collect(); - for p in page_refs(doc) { - let Some(a) = doc.get(p).as_dict().and_then(|d| d.get(b"Annots").cloned()) else { continue }; - let list = doc.resolve(&a).as_array().cloned().unwrap_or_default(); - let kept: Vec = list.iter().filter(|o| !o.as_ref().is_some_and(|r| widgets.contains(&r))).cloned().collect(); - if kept.len() != list.len() { - match a.as_ref() { - Some(r) if doc.get(r).as_array().is_some() => doc.set(r, Object::Array(kept)), - _ => doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array(kept)))?, - } - } - } - detach(doc, f.obj)?; - // Out of the calculation order too: a `/CO` entry would keep the field, its value and its - // widgets reachable for a full save. - drop_from_calculation_order(doc, |r| r == f.obj || widgets.contains(&r)) -} - -/// Remove the `/CO` entries `gone` matches, whether the form and the array are held directly or -/// by reference. -fn drop_from_calculation_order(doc: &mut Document, gone: impl Fn(ObjRef) -> bool) -> Result<(), FormError> { - let Some(root) = doc.root() else { return Ok(()) }; - let Some(af_obj) = doc.get(root).as_dict().and_then(|d| d.get(b"AcroForm").cloned()) else { return Ok(()) }; - let Some(mut af) = doc.resolve(&af_obj).as_dict().cloned() else { return Ok(()) }; - let Some(co_obj) = af.get(b"CO").cloned() else { return Ok(()) }; - let co = doc.resolve(&co_obj).as_array().cloned().unwrap_or_default(); - let kept: Vec = co.iter().filter(|o| !o.as_ref().is_some_and(&gone)).cloned().collect(); - if kept.len() == co.len() { - return Ok(()); - } - match (co_obj.as_ref(), af_obj.as_ref()) { - (Some(r), _) if doc.get(r).as_array().is_some() => doc.set(r, Object::Array(kept)), - (_, Some(af_ref)) => doc.update_dict(af_ref, |d| d.set(b"CO".to_vec(), Object::Array(kept)))?, - (_, None) => { - af.set(b"CO".to_vec(), Object::Array(kept)); - doc.update_dict(root, |d| d.set(b"AcroForm".to_vec(), Object::Dict(af)))?; - } - } - Ok(()) -} - -/// Widget-only keys: they stay with each widget when a merged field is split. -const WIDGET_KEYS: [&[u8]; 12] = [b"Type", b"Subtype", b"Rect", b"P", b"AP", b"AS", b"MK", b"F", b"BS", b"Border", b"H", b"StructParent"]; - -/// Duplicate a field onto other pages (Acrobat: right-click a field ▸ Duplicate): each page -/// gets a widget at the same place, belonging to the same field, so they share one value. -/// A field that is its own widget is first split into a field and a widget kid. Returns how -/// many widgets were added (pages that already have one are skipped). -pub fn duplicate_field(doc: &mut Document, name: &str, pages: &[usize]) -> Result { - let f = fields(doc).into_iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?; - let model = f.widgets.first().cloned().ok_or_else(|| FormError::Invalid(format!("{name} has no widget")))?; - let refs = page_refs(doc); - for p in pages { - if *p >= refs.len() { - return invalid(format!("page {} does not exist", p + 1)); - } - } - let have: std::collections::HashSet = f.widgets.iter().filter_map(|w| w.page).collect(); - let targets: Vec = pages.iter().copied().filter(|p| !have.contains(p)).collect(); - if targets.is_empty() { - return Ok(0); - } - // A merged field/widget becomes a field with one widget kid. - let mut model_ref = model.obj; - if model.obj == f.obj { - let d = doc.get(f.obj).as_dict().cloned().unwrap_or_default(); - let mut kid = Dict::new(); - for k in WIDGET_KEYS { - if let Some(v) = d.get(k) { - kid.set(k.to_vec(), v.clone()); - } - } - kid.set(b"Parent".to_vec(), Object::Ref(f.obj)); - let kr = doc.add(Object::Dict(kid)); - doc.update_dict(f.obj, |d| { - for k in WIDGET_KEYS { - d.remove(k); - } - d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(kr)])); - })?; - // The page lists the widget now, not the field. - if let Some(p) = model.page { - let pr = refs[p]; - let annots = doc.get(pr).as_dict().and_then(|d| d.get(b"Annots").cloned()); - let swap = - |a: Vec| -> Vec { a.into_iter().map(|o| if o.as_ref() == Some(f.obj) { Object::Ref(kr) } else { o }).collect() }; - match annots { - Some(Object::Ref(ar)) => { - let a = doc.get(ar).as_array().cloned().unwrap_or_default(); - doc.set(ar, Object::Array(swap(a))); - } - Some(Object::Array(a)) => doc.update_dict(pr, |d| d.set(b"Annots".to_vec(), Object::Array(swap(a))))?, - _ => {} - } - } - model_ref = kr; - } - let template = doc.get(model_ref).as_dict().cloned().unwrap_or_default(); - let mut added = Vec::new(); - for p in &targets { - let pr = refs[*p]; - let mut w = template.clone(); - w.set(b"P".to_vec(), Object::Ref(pr)); - w.set(b"Parent".to_vec(), Object::Ref(f.obj)); - w.remove(b"StructParent"); - let wr = doc.add(Object::Dict(w)); - let annots = doc.get(pr).as_dict().and_then(|d| d.get(b"Annots").cloned()); - match annots { - Some(Object::Ref(ar)) => { - let mut a = doc.get(ar).as_array().cloned().unwrap_or_default(); - a.push(Object::Ref(wr)); - doc.set(ar, Object::Array(a)); - } - other => { - let mut a = other.and_then(|o| o.as_array().cloned()).unwrap_or_default(); - a.push(Object::Ref(wr)); - doc.update_dict(pr, |d| d.set(b"Annots".to_vec(), Object::Array(a)))?; - } - } - added.push(wr); - } - doc.update_dict(f.obj, |d| { - let mut kids = d.get(b"Kids").and_then(|k| k.as_array().cloned()).unwrap_or_default(); - kids.extend(added.iter().map(|r| Object::Ref(*r))); - d.set(b"Kids".to_vec(), Object::Array(kids)); - })?; - redraw_field(doc, name)?; - Ok(added.len()) -} diff --git a/crates/forms/src/author/edit.rs b/crates/forms/src/author/edit.rs new file mode 100644 index 0000000000..2d05600ae2 --- /dev/null +++ b/crates/forms/src/author/edit.rs @@ -0,0 +1,615 @@ +use super::join::{check_join, join_radio_group}; +use super::look::{BorderStyle, da_string, patch_da, patch_widget_look, redraw_widgets}; +use super::tree::{ancestor_sets, attach, check_place, child_named, clash, detach, ensure_form, inherited, name_parts, own_inherited}; +use super::{Dict, Document, Field, FieldKind, FieldProps, FormError, MIN_FIELD_SIDE, ObjRef, Object, PdfString, fields, flags, invalid}; + +/// Swap width and height, keeping the center. `None` when that is not a usable widget. +fn swapped_around_center(r: [f64; 4]) -> Option<[f64; 4]> { + if !r.iter().all(|v| v.is_finite()) { + return None; + } + let (x0, y0, x1, y1) = (r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])); + let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0); + let (nw, nh) = (y1 - y0, x1 - x0); + let out = [cx - nw / 2.0, cy - nh / 2.0, cx + nw / 2.0, cy + nh / 2.0]; + (out.iter().all(|v| v.is_finite()) && out[2] - out[0] >= MIN_FIELD_SIDE && out[3] - out[1] >= MIN_FIELD_SIDE).then_some(out) +} + +/// Re-point a choice field's `/I`, positions in `/Opt`, at the new list `exports` after `/Opt` was +/// replaced (ISO 32000-2 §12.7.5.4): the positions of the selected `values` still offered. `kept` +/// are the old `/I` positions carried into the new list, which tell apart options sharing an +/// export value; a selected value none of them covers takes its first position, by `set_value`'s +/// rule. Returns them. +fn repoint_selection(d: &mut Dict, values: &[String], exports: &[String], kept: &[usize]) -> Vec { + let mut indices: Vec = kept.iter().copied().filter(|&i| exports.get(i).is_some_and(|e| values.contains(e))).collect(); + for v in values { + if !indices.iter().any(|&i| exports.get(i) == Some(v)) { + indices.extend(exports.iter().position(|e| e == v)); + } + } + indices.sort_unstable(); + indices.dedup(); + if indices.is_empty() { + d.remove(b"I"); + } else { + d.set(b"I".to_vec(), Object::Array(indices.iter().map(|&i| Object::Int(i64::try_from(i).unwrap_or(i64::MAX))).collect())); + } + indices +} + +/// The positions in `/Opt` that a choice field's `/I` holds, those below `count`. +fn selected_indices(doc: &Document, field: ObjRef, count: usize) -> Vec { + let Some(i) = doc.get(field).as_dict().and_then(|d| d.get(b"I").cloned()) else { + return Vec::new(); + }; + let positions = doc.resolve(&i); + positions.as_array().map(|a| a.iter().filter_map(|o| usize::try_from(o.as_int()?).ok()).filter(|&n| n < count).collect()).unwrap_or_default() +} + +/// The items (export value, display text) "Sort items" orders: the new `props.options`, or else +/// the field's own, whose selection, `/I` included, moves with them. +fn items_to_sort(doc: &Document, f: &Field, props: &FieldProps) -> (Vec<(String, String)>, Vec) { + match &props.options { + Some(o) => (o.iter().map(|x| (x.clone(), x.clone())).collect(), Vec::new()), + None => (f.options.clone(), selected_indices(doc, f.obj, f.options.len())), + } +} + +/// Sort a choice field's items (export value, display text) by display text, ignoring case, and +/// write them as `/Opt`; export values stay with their items. Returns the export values in their +/// new order and the `old` positions carried through the sort. +fn write_sorted_opt(d: &mut Dict, items: &[(String, String)], old: &[usize]) -> (Vec, Vec) { + let mut order: Vec = (0..items.len()).collect(); + order.sort_by_key(|&i| items.get(i).map(|(_, display)| display.to_lowercase())); + let sorted: Vec<&(String, String)> = order.iter().filter_map(|&i| items.get(i)).collect(); + let opt = sorted + .iter() + .map(|(e, disp)| { + if e == disp { + Object::String(PdfString::text(disp)) + } else { + Object::Array(vec![Object::String(PdfString::text(e)), Object::String(PdfString::text(disp))]) + } + }) + .collect(); + d.set(b"Opt".to_vec(), Object::Array(opt)); + let carried = old.iter().filter_map(|o| order.iter().position(|i| i == o)).collect(); + (sorted.into_iter().map(|(e, _)| e.clone()).collect(), carried) +} + +/// Build /Opt before modifying the document, preserving appearance states and selection. +fn button_export_options(f: &Field, index: usize, value: &str) -> Result, FormError> { + if !matches!(f.kind, FieldKind::CheckBox | FieldKind::Radio) { + return invalid("only check boxes and radio buttons have an export value"); + } + if value.is_empty() || value == "Off" || value.len() > 4096 { + return invalid("export value must be non-empty, at most 4096 bytes, and not Off"); + } + let widget = f.widgets.get(index).ok_or_else(|| FormError::Invalid("button widget index is out of range".into()))?; + let state = widget.on_state.as_deref().ok_or_else(|| FormError::Invalid("button has no on appearance state".into()))?; + if !f.options.is_empty() && f.options.len() != f.widgets.len() { + return invalid("button export options do not match its widgets"); + } + let previous = f.export_of(index); + let mut out = Vec::with_capacity(f.widgets.len()); + for (i, w) in f.widgets.iter().enumerate() { + let export = f.export_of(i).ok_or_else(|| FormError::Invalid("button has no on appearance state".into()))?; + let same_state = w.on_state.as_deref() == Some(state); + if !same_state && (export == value || w.on_state.as_deref() == Some(value)) { + return invalid("export value conflicts with another button choice"); + } + if same_state && Some(export) != previous { + return invalid("widgets with the same appearance state have conflicting export values"); + } + out.push(Object::String(PdfString::text(if same_state { value } else { export }))); + } + Ok(out) +} + +/// Change a field's properties (General and Options tabs) and redraw it. +pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result { + if props.font_size.is_some_and(|size| !size.is_finite() || size < 0.0) { + return invalid("font size must be a finite, non-negative number"); + } + if let Some(patch) = &props.appearance { + if props.look.is_some() { + return invalid("give an appearance patch or a complete look, not both"); + } + patch.validate()?; + } + // Refused here, before anything is written, rather than stored as a different formula. + if let Some(crate::af::Calculate::Notation(n)) = &props.calculate + && !crate::af::notation_storable(n) + { + return invalid(crate::af::NOTATION_NOT_STORABLE); + } + let all = fields(doc); + let f = all.iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?.clone(); + let export_options = props.export_value.as_ref().map(|(index, value)| button_export_options(&f, *index, value)).transpose()?; + if props.export_value.is_some() && props.options.is_some() { + return invalid("give export_value or options, not both"); + } + // Refuse a wrong type/index before any other requested property is written. + if let Some((wi, _)) = &props.caption { + if f.kind != FieldKind::PushButton { + return invalid("only push buttons have a caption"); + } + if f.widgets.get(*wi).is_none() { + return invalid(format!("{name} has no widget {}", wi.saturating_add(1))); + } + } + // Validated before anything is written, so a refused rotation leaves the document unchanged. + // `Some(Some(rect))`: the widget's axis changes and it gets the swapped rectangle. + let mut rotated_rect: Option> = None; + if let Some((wi, rot)) = props.rotation { + if !matches!(rot, 0 | 90 | 180 | 270) { + return invalid("rotation must be 0, 90, 180 or 270 degrees"); + } + let Some(w) = f.widgets.get(wi) else { + return invalid(format!("{name} has no widget {}", wi.saturating_add(1))); + }; + let axis_change = (w.rotation % 180 == 0) != (rot % 180 == 0); + rotated_rect = Some(None); + if axis_change && props.rect.is_none() { + let Some(rect) = swapped_around_center(w.rect) else { return invalid("the field is too small") }; + rotated_rect = Some(Some(rect)); + } + } + // A locked field only takes unlocking (Acrobat greys its properties out). + if f.locked() && props.locked != Some(false) && *props != (FieldProps { locked: props.locked, ..FieldProps::default() }) { + return invalid(format!("{name} is locked: unlock it first")); + } + if let Some(options) = export_options.as_ref() + && *props == (FieldProps { export_value: props.export_value.clone(), ..FieldProps::default() }) + { + doc.update_dict(f.obj, |d| d.set(b"Opt".to_vec(), Object::Array(options.clone())))?; + return Ok(name.to_string()); + } + if let Some(lock) = props.locked { + for w in &f.widgets { + let flags = doc.get(w.obj).as_dict().and_then(|d| d.get(b"F").and_then(|v| doc.resolve(v).as_int())).unwrap_or(0); + let v = if lock { flags | 128 } else { flags & !128 }; + doc.update_dict(w.obj, |d| d.set(b"F".to_vec(), Object::Int(v)))?; + } + if *props == (FieldProps { locked: props.locked, ..FieldProps::default() }) { + return Ok(name.to_string()); + } + } + if let Some(acts) = &props.actions { + crate::set_field_actions(doc, name, acts)?; + } + // A radio button renamed to another radio group's name joins that group (#4949). The other + // properties are written first, to this field, and its buttons then move. + let (new_name, join) = rename_field(doc, &all, &f, name, props)?; + // The new default appearance when the size or the look changes. + let size = props.font_size.unwrap_or_else(|| crate::appearance::parse_da(&f.da).size); + let new_da = match &props.look { + Some(l) => da_string(l.font, size, l.text), + None => patch_da(&f.da, props.font_size, &props.appearance.unwrap_or_default()), + }; + if props.look.is_some() || props.appearance.is_some_and(|p| p.font.is_some()) { + ensure_form(doc)?; + } + let mut ff = f.flags; + let mut set_flag = |flag: u32, on: Option| { + if let Some(on) = on { + if on { + ff |= flag; + } else { + ff &= !flag; + } + } + }; + set_flag(flags::READ_ONLY, props.read_only); + set_flag(flags::REQUIRED, props.required); + if f.kind == FieldKind::Text { + set_flag(flags::MULTILINE, props.multiline); + } + for (flag, on) in &props.flags { + set_flag(*flag, Some(*on)); + } + // A comb spreads a fixed number of characters across the field: it needs a limit and + // excludes multi-line, password, file selection and scrolling (ISO 32000-2 Table 229). + if f.kind == FieldKind::Text && ff & flags::COMB != 0 { + let limit = match props.max_len { + Some(m) => m, + None => f.max_len, + }; + if limit.is_none_or(|m| m == 0) { + return invalid("a comb of characters needs a character limit"); + } + ff &= !(flags::MULTILINE | flags::PASSWORD | flags::FILE_SELECT); + } + if let Some(q) = props.quadding + && !(0..=2).contains(&q) + { + return invalid("alignment is 0 (left), 1 (centre) or 2 (right)"); + } + if props.options.as_ref().is_some_and(Vec::is_empty) && matches!(f.kind, FieldKind::Combo | FieldKind::List) { + return invalid("a list needs at least one option"); + } + if props.check_style.is_some() && !matches!(f.kind, FieldKind::CheckBox | FieldKind::Radio) { + return invalid("only check boxes and radio buttons have a check style"); + } + let mut list_picked = None; + let (sort_items, old_selection) = items_to_sort(doc, &f, props); + let parent_default = ancestor_sets(doc, f.obj, b"DV"); + doc.update_dict(f.obj, |d| { + d.set(b"Ff".to_vec(), Object::Int(ff as i64)); + if let Some(t) = &props.tooltip { + if t.is_empty() { + d.remove(b"TU"); + } else { + d.set(b"TU".to_vec(), PdfString::text(t)); + } + } + if let Some(m) = props.max_len { + match m { + Some(m) => d.set(b"MaxLen".to_vec(), Object::Int(m as i64)), + None => { + d.remove(b"MaxLen"); + } + } + } + // The export values in `/Opt` order once it is rewritten, for re-pointing `/I`. + let mut new_exports: Option> = None; + let mut kept = Vec::new(); + if let Some(options) = &export_options { + d.set(b"Opt".to_vec(), Object::Array(options.clone())); + } + if let Some(opts) = &props.options { + d.set(b"Opt".to_vec(), Object::Array(opts.iter().map(|o| Object::String(PdfString::text(o))).collect())); + new_exports = Some(opts.clone()); + } + // "Sort items": the list itself is kept in order (export values stay with their items). + if ff & flags::SORT != 0 && matches!(f.kind, FieldKind::Combo | FieldKind::List) { + let (exports, carried) = write_sorted_opt(d, &sort_items, &old_selection); + new_exports = Some(exports); + kept = carried; + } + if let Some(exports) = &new_exports + && matches!(f.kind, FieldKind::Combo | FieldKind::List) + { + list_picked = Some((repoint_selection(d, &f.value, exports, &kept), exports.len())); + } + if let Some(q) = props.quadding { + d.set(b"Q".to_vec(), Object::Int(q)); + } + match &props.default_value { + // Buttons name their default state; text and choices hold a string. + Some(Some(v)) if matches!(f.kind, FieldKind::CheckBox | FieldKind::Radio) => d.set(b"DV".to_vec(), Object::Name(crate::name_bytes(v))), + Some(Some(v)) => d.set(b"DV".to_vec(), PdfString::text(v)), + // `/DV` is inheritable: with an ancestor's in place, removing the field's own would + // bring that one back, so the field keeps an empty default of its own instead. + Some(None) if parent_default && matches!(f.kind, FieldKind::CheckBox | FieldKind::Radio) => { + d.set(b"DV".to_vec(), Object::Name(b"Off".to_vec())) + } + Some(None) if parent_default => d.set(b"DV".to_vec(), PdfString::text("")), + Some(None) => { + d.remove(b"DV"); + } + None => {} + } + if props.font_size.is_some() || props.look.is_some() || props.appearance.is_some_and(|p| p.font.is_some() || p.text.is_some()) { + d.set(b"DA".to_vec(), PdfString::literal(new_da.clone().into_bytes())); + } + })?; + if props.appearance.is_some() || (props.font_size.is_some() && props.look.is_none()) { + let patch = &props.appearance.unwrap_or_default(); + for w in &f.widgets { + patch_widget_look(doc, w, patch)?; + // A widget's own /DA overrides its field's /DA. Patch that override separately + // rather than replacing its font or colour with the first widget's defaults. + if w.obj != f.obj && (props.font_size.is_some() || patch.font.is_some() || patch.text.is_some()) { + let da = doc + .get(w.obj) + .as_dict() + .and_then(|d| d.get(b"DA").cloned()) + .map(|v| doc.resolve(&v)) + .and_then(|v| v.as_string().map(|s| s.to_text())); + if let Some(da) = da { + let da = patch_da(&da, props.font_size, patch); + doc.update_dict(w.obj, |d| d.set(b"DA".to_vec(), PdfString::literal(da.into_bytes())))?; + } + } + } + } + apply_widget_style_props(doc, &f, name, props)?; + if props.format.is_some() || props.validate.is_some() || props.calculate.is_some() { + if !matches!(f.kind, FieldKind::Text | FieldKind::Combo) { + return invalid("only text fields and drop-down lists have format, validate and calculate scripts"); + } + let mut aa = doc + .get(f.obj) + .as_dict() + .and_then(|d| d.get(b"AA").cloned()) + .map(|a| doc.resolve(&a)) + .and_then(|a| a.as_dict().cloned()) + .unwrap_or_default(); + let action = |js: &str| -> Object { + let mut a = Dict::new(); + a.set(b"S".to_vec(), Object::name("JavaScript")); + a.set(b"JS".to_vec(), PdfString::literal(js.as_bytes().to_vec())); + Object::Dict(a) + }; + if let Some(fm) = &props.format { + aa.remove(b"F"); + aa.remove(b"K"); + if let Some((fjs, kjs)) = crate::af::format_js(fm) { + if !fjs.is_empty() { + aa.set(b"F".to_vec(), action(&fjs)); + } + aa.set(b"K".to_vec(), action(&kjs)); + } + } + if let Some(v) = &props.validate { + aa.remove(b"V"); + if let Some(js) = crate::af::validate_js(v) { + aa.set(b"V".to_vec(), action(&js)); + } + } + if let Some(c) = &props.calculate { + aa.remove(b"C"); + if let Some(js) = crate::af::calculate_js(c) { + aa.set(b"C".to_vec(), action(&js)); + } + crate::calc_order::keep_listed(doc, f.obj, *c != crate::af::Calculate::None)?; + } + doc.update_dict(f.obj, |d| { + if aa.is_empty() { + d.remove(b"AA"); + } else { + d.set(b"AA".to_vec(), Object::Dict(aa)); + } + })?; + } + let only = apply_late_widget_props(doc, &f, props, &new_da, rotated_rect)?; + scroll_list(doc, f.obj, list_picked)?; + redraw_or_join(doc, &f, name, &new_name, join.as_ref(), only)?; + if props.calculate.is_some() { + crate::recalculate(doc)?; + } + Ok(new_name) +} + +/// Apply a new name from `props`: rename the field, move it under the parents a full name +/// names, or, for a radio button named after another radio group, pick that group to join. +/// Returns the field's new full name and the group it joins. +fn rename_field(doc: &mut Document, all: &[Field], f: &Field, name: &str, props: &FieldProps) -> Result<(String, Option), FormError> { + let mut new_name = name.to_string(); + let mut join = None; + if let Some(n) = props.name.as_deref().map(str::trim).filter(|n| *n != f.name) { + // A full name under the same parents only renames the field's own part (the editor + // shows nested fields by their full name): it isn't moved, so it keeps its place and + // goes on inheriting from its parents. + let n = match (n.rsplit_once('.'), f.name.rsplit_once('.')) { + (Some((parents, own)), Some((current, _))) if parents == current => own, + _ => n, + }; + let full = match f.name.rsplit_once('.') { + Some((parents, _)) if !n.contains('.') => format!("{parents}.{n}"), + _ => n.to_string(), + }; + let group = all.iter().find(|x| x.obj != f.obj && x.kind == FieldKind::Radio && x.name == full); + if let Some(group) = group.filter(|_| f.kind == FieldKind::Radio) { + check_join(doc, f, group)?; + join = Some(group.clone()); + new_name = full; + } else if n.contains('.') { + // A full name: the field moves under the parent fields it names (#2696). + let parts = name_parts(n)?; + if let Some(why) = all.iter().filter(|x| x.obj != f.obj).find_map(|x| clash(n, &x.name)) { + return invalid(why); + } + let af = ensure_form(doc)?; + // Checked before anything changes, so a refused move leaves the field as it was. + let add = inherited(doc, f.obj); + check_place(doc, af, f.obj, &parts, &add)?; + // From here a failure (a write that `check_place` couldn't foresee) would leave the + // field half moved in `doc`. The engine runs every edit on a copy of the document and + // keeps it only when the edit succeeds (`Session::apply`), so the open document never + // sees that. + own_inherited(doc, f.obj, &add)?; + detach(doc, f.obj)?; + attach(doc, af, f.obj, &parts)?; + new_name = n.to_string(); + } else { + if n.is_empty() { + return invalid("field names can't be empty"); + } + // Rename the terminal part (the field's own /T). The parents' part stays, so the name + // that must be free is the full one: `MASTER.a` renamed to `b` takes `MASTER.b` (ISO + // 32000-2 §12.7.4.2). The field itself is no clash when it keeps its own name. + let prefix = f.name.rsplit_once('.').map(|(p, _)| format!("{p}.")).unwrap_or_default(); + new_name = format!("{prefix}{n}"); + if let Some(why) = all.iter().filter(|x| x.obj != f.obj).find_map(|x| clash(&new_name, &x.name)) { + return invalid(why); + } + // A node of that name beside it that isn't a terminal field, such as a parent + // field left empty by a move. + let af = ensure_form(doc)?; + let parent = doc.get(f.obj).as_dict().and_then(|d| d.reference(b"Parent")); + if child_named(doc, af, parent, n).is_some_and(|r| r != f.obj) { + return invalid(format!("a field named {new_name:?} already exists")); + } + doc.update_dict(f.obj, |d| d.set(b"T".to_vec(), PdfString::text(n)))?; + } + // Calculations read the field by its new name, unless another field keeps the old one. + if all.iter().filter(|x| x.name == f.name).count() == 1 { + rename_in_calculations(doc, all, &f.name, &new_name)?; + } + } + Ok((new_name, join)) +} + +fn apply_widget_style_props(doc: &mut Document, f: &Field, name: &str, props: &FieldProps) -> Result<(), FormError> { + if let Some(l) = &props.look { + let arr = |c: [f64; 3]| Object::Array(c.iter().map(|v| Object::Real(v.clamp(0.0, 1.0))).collect()); + for w in &f.widgets { + // Resolved first: an indirect /MK keeps its mark, rotation and other entries. + let mut mk = doc.get(w.obj).as_dict().and_then(|d| d.get(b"MK").map(|m| doc.resolve(m).as_dict().cloned())).flatten().unwrap_or_default(); + doc.update_dict(w.obj, |d| { + match l.border { + Some(c) => mk.set(b"BC".to_vec(), arr(c)), + None => { + mk.remove(b"BC"); + } + } + match l.fill { + Some(c) => mk.set(b"BG".to_vec(), arr(c)), + None => { + mk.remove(b"BG"); + } + } + d.set(b"MK".to_vec(), Object::Dict(mk)); + let mut bs = Dict::new(); + bs.set(b"W".to_vec(), Object::Real(l.width.clamp(0.0, 12.0))); + bs.set(b"S".to_vec(), Object::name(l.style.code())); + if l.style == BorderStyle::Dashed { + bs.set(b"D".to_vec(), Object::Array(vec![Object::Int(3)])); + } + d.set(b"BS".to_vec(), Object::Dict(bs)); + })?; + } + } + if let Some(style) = props.check_style { + for w in &f.widgets { + // Resolved first: an indirect /MK keeps its colours. + let mut mk = doc.get(w.obj).as_dict().and_then(|d| d.get(b"MK").map(|m| doc.resolve(m).as_dict().cloned())).flatten().unwrap_or_default(); + mk.set(b"CA".to_vec(), PdfString::text(style.code())); + doc.update_dict(w.obj, |d| d.set(b"MK".to_vec(), Object::Dict(mk)))?; + } + } + if let Some((wi, r)) = props.rect { + let w = f.widgets.get(wi).ok_or_else(|| FormError::Invalid(format!("{name} has no widget {}", wi + 1)))?; + let r = [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])]; + if !r.iter().all(|v| v.is_finite()) || r[2] - r[0] < MIN_FIELD_SIDE || r[3] - r[1] < MIN_FIELD_SIDE { + return invalid("the field is too small"); + } + doc.update_dict(w.obj, |d| d.set(b"Rect".to_vec(), Object::Array(r.iter().map(|v| Object::Real(*v)).collect())))?; + } + Ok(()) +} + +/// Redraw the edited widgets. A radio button joining another group (`join`) is redrawn under its +/// old name `name` first; then its buttons move, and only they are redrawn as `new_name`, so the +/// group's own buttons keep their appearances. +fn redraw_or_join(doc: &mut Document, f: &Field, name: &str, new_name: &str, join: Option<&Field>, only: Option) -> Result<(), FormError> { + if let Some(group) = join { + redraw_widgets(doc, name, only)?; + join_radio_group(doc, f, group)?; + let moved: Vec = fields(doc) + .into_iter() + .find(|g| g.name == new_name) + .map(|g| g.widgets.iter().enumerate().filter(|(_, w)| f.widgets.iter().any(|m| m.obj == w.obj)).map(|(i, _)| i).collect()) + .unwrap_or_default(); + for index in moved { + redraw_widgets(doc, new_name, Some(index))?; + } + Ok(()) + } else { + redraw_widgets(doc, new_name, only) + } +} + +/// A list box scrolls, as for a new value, so its first row is still an option and the +/// selection is in view. Its rows are measured with the edited font size and rectangle. +fn scroll_list(doc: &mut Document, field: ObjRef, list_picked: Option<(Vec, usize)>) -> Result<(), FormError> { + if let Some((picked, count)) = list_picked + && let Some(g) = fields(doc).into_iter().find(|g| g.obj == field) + { + if let Some((old, new)) = crate::values::list_scroll(&g, &picked, count) + && old != new + { + doc.update_dict(field, |d| d.set(b"TI".to_vec(), Object::Int(i64::try_from(new).unwrap_or(0))))?; + } + crate::values::scroll_widgets(doc, &g, &picked, count)?; + } + Ok(()) +} + +fn apply_late_widget_props( + doc: &mut Document, + f: &Field, + props: &FieldProps, + new_da: &str, + rotated_rect: Option>, +) -> Result, FormError> { + // A complete look replaces widget /DA overrides. Partial changes were patched above + // against each override, including size-only changes that keep its font and colour. + if props.look.is_some() { + for w in &f.widgets { + if w.obj != f.obj { + doc.update_dict(w.obj, |d| { + if d.contains(b"DA") { + d.set(b"DA".to_vec(), PdfString::literal(new_da.to_owned().into_bytes())); + } + })?; + } + } + } + if let (Some((wi, rot)), Some(swapped)) = (props.rotation, rotated_rect) + && let Some(w) = f.widgets.get(wi) + { + if let Some(rect) = swapped { + doc.update_dict(w.obj, |d| d.set(b"Rect".to_vec(), Object::Array(rect.iter().map(|v| Object::Real(*v)).collect())))?; + } + let mut mk = doc.get(w.obj).as_dict().and_then(|d| d.get(b"MK").map(|m| doc.resolve(m).as_dict().cloned())).flatten().unwrap_or_default(); + if rot == 0 { + mk.remove(b"R"); + } else { + mk.set(b"R".to_vec(), Object::Int(rot)); + } + doc.update_dict(w.obj, |d| { + if mk.is_empty() { + d.remove(b"MK"); + } else { + d.set(b"MK".to_vec(), Object::Dict(mk)); + } + })?; + } + if let Some((wi, caption)) = &props.caption + && let Some(w) = f.widgets.get(*wi) + { + // Copy an indirect/shared MK before patching it; colours, icons, rotation and + // unknown entries belong to this widget, and other owners keep their old label. + let mut mk = doc.get(w.obj).as_dict().and_then(|d| d.get(b"MK").map(|m| doc.resolve(m).as_dict().cloned())).flatten().unwrap_or_default(); + mk.set(b"CA".to_vec(), PdfString::text(caption)); + doc.update_dict(w.obj, |d| d.set(b"MK".to_vec(), Object::Dict(mk)))?; + } + // A label-only edit must not replace another widget's custom appearances. + let only = props.caption.as_ref().filter(|_| *props == (FieldProps { caption: props.caption.clone(), ..Default::default() })).map(|(wi, _)| *wi); + Ok(only) +} + +/// Name field `old` `new` in each field's Calculate script that reads it +/// ([`crate::af::rename_in_calculation`]). +fn rename_in_calculations(doc: &mut Document, all: &[Field], old: &str, new: &str) -> Result<(), FormError> { + let aa_of = + |doc: &Document, r: ObjRef| doc.get(r).as_dict().and_then(|d| d.get(b"AA").map(|a| doc.resolve(a))).and_then(|a| a.as_dict().cloned()); + for f in all { + // The script is the field's, or its only widget's when the field has none, as + // `actions_of` reads it. + let only_widget = || match f.widgets.as_slice() { + [w] => aa_of(doc, w.obj).map(|aa| (w.obj, aa)), + _ => None, + }; + let Some((owner, mut aa)) = aa_of(doc, f.obj).map(|aa| (f.obj, aa)).or_else(only_widget) else { continue }; + let Some(mut action) = aa.get(b"C").and_then(|a| doc.resolve(a).as_dict().cloned()) else { continue }; + let js = match action.get(b"JS").map(|js| doc.resolve(js)).as_deref() { + Some(Object::String(s)) => s.to_text(), + // A stream that isn't UTF-8 reads with replacement characters, which must not be + // written back in place of what it holds. + Some(Object::Stream(s)) => match s.decoded().ok().and_then(|b| String::from_utf8(b).ok()) { + Some(js) => js, + None => continue, + }, + _ => continue, + }; + let Some(js) = crate::af::rename_in_calculation(&js, old, new) else { continue }; + action.set(b"JS".to_vec(), PdfString::text(&js)); + aa.set(b"C".to_vec(), Object::Dict(action)); + doc.update_dict(owner, |d| d.set(b"AA".to_vec(), Object::Dict(aa)))?; + } + Ok(()) +} diff --git a/crates/forms/src/author/join.rs b/crates/forms/src/author/join.rs new file mode 100644 index 0000000000..e9fedbd655 --- /dev/null +++ b/crates/forms/src/author/join.rs @@ -0,0 +1,129 @@ +//! Renaming a radio button to an existing radio group's name makes it a button of that group +//! (#4949): radio buttons are one group when they are widgets of one field (ISO 32000-2 +//! §12.7.5.2.4), and a button's export value is the name of its "on" appearance state. + +use std::collections::{HashMap, HashSet}; + +use super::lifecycle::{drop_from_calculation_order, split_merged, split_triggers}; +use super::tree::{detach, list_of}; +use super::{Dict, Document, Field, FormError, ObjRef, Object, invalid}; + +/// The keys a field that is also its own widget sheds when it becomes only a widget. Its `/AA` +/// keeps only the widget's own triggers. +const FIELD_ONLY_KEYS: [&[u8]; 15] = [b"T", b"FT", b"Kids", b"Ff", b"V", b"DV", b"Opt", b"TU", b"TM", b"MaxLen", b"Q", b"DS", b"RV", b"TI", b"I"]; + +/// Whether `field` holds `/Opt`, which its buttons' export values index (ISO 32000-2 §12.7.5.2). +fn has_opt(doc: &Document, field: ObjRef) -> bool { + doc.get(field).as_dict().is_some_and(|d| d.contains(b"Opt")) +} + +/// Refuse what [`join_radio_group`] can't do, before anything is written. +pub(super) fn check_join(doc: &Document, f: &Field, target: &Field) -> Result<(), FormError> { + for x in [f, target] { + if has_opt(doc, x.obj) { + return invalid(format!("{} lists its export values in /Opt, so its buttons can't move between groups", x.name)); + } + if x.widgets.is_empty() { + return invalid(format!("{} has no button", x.name)); + } + } + if target.locked() { + return invalid(format!("{} is locked: unlock it first", target.name)); + } + Ok(()) +} + +/// Move every button of radio field `f` into radio field `target`. Each keeps its export value +/// unless a button of `target` already has it; then it takes the first free "ChoiceN", as a +/// button added to a group does, and buttons of `f` that shared a value still share the new one. A moved button shows selected only if it is `target`'s +/// selected value. `f` leaves the form; its own value and field-level properties go with it, +/// and `target`'s stay. Call [`check_join`] first. +pub(super) fn join_radio_group(doc: &mut Document, f: &Field, target: &Field) -> Result<(), FormError> { + let theirs: HashSet = target.widgets.iter().filter_map(|w| w.on_state.clone()).collect(); + let mut taken = theirs.clone(); + taken.extend(f.widgets.iter().filter_map(|w| w.on_state.clone())); + // The group's value counts only when one of its buttons has it. + let selected = target.value.first().filter(|v| theirs.contains(*v)).cloned(); + // Each clashing value of `f` gets one fresh name, shared by every button that had it. Of + // `taken.len() + 1` names at least one is free, so the search always ends in one. + let mut renamed: HashMap = HashMap::new(); + for old in f.widgets.iter().filter_map(|w| w.on_state.as_deref()).filter(|s| theirs.contains(*s)) { + if renamed.contains_key(old) { + continue; + } + let Some(fresh) = (1..=taken.len().saturating_add(1)).map(|i| format!("Choice{i}")).find(|c| !taken.contains(c)) else { + return invalid(format!("no free export value for {old}")); + }; + taken.insert(fresh.clone()); + renamed.insert(old.to_owned(), fresh); + } + // A group that is its own one button gets a field with a separate widget first, so it can + // hold more than one. + if let [only] = target.widgets.as_slice() + && only.obj == target.obj + { + split_merged(doc, target, only)?; + } + detach(doc, f.obj)?; + // `f` is no longer a field, even when it stays on as a widget. + drop_from_calculation_order(doc, |r| r == f.obj)?; + let mut kids = Vec::with_capacity(f.widgets.len()); + for w in &f.widgets { + let mut d = doc.get(w.obj).as_dict().cloned().unwrap_or_default(); + if w.obj == f.obj { + for k in FIELD_ONLY_KEYS { + d.remove(k); + } + let aa = d.get(b"AA").and_then(|a| doc.resolve(a).as_dict().cloned()); + match aa.as_ref().map(split_triggers) { + Some((Some(widget_aa), _)) => d.set(b"AA".to_vec(), Object::Dict(widget_aa)), + Some((None, _)) => { + d.remove(b"AA"); + } + None => {} + } + } + let mut state = w.on_state.clone(); + if let Some(old) = w.on_state.as_deref() + && let Some(fresh) = renamed.get(old) + && rename_state(doc, &mut d, old, fresh) + { + state = Some(fresh.clone()); + } + let on = state.is_some() && state == selected; + let shown = match (&state, on) { + (Some(s), true) => Object::Name(crate::name_bytes(s)), + _ => Object::name("Off"), + }; + d.set(b"AS".to_vec(), shown); + d.set(b"Parent".to_vec(), Object::Ref(target.obj)); + doc.set(w.obj, Object::Dict(d)); + kids.push(Object::Ref(w.obj)); + } + // `/Kids` may be an indirect array: read through it so the group's own buttons stay. + let mut list = list_of(doc, target.obj, b"Kids"); + list.extend(kids); + doc.update_dict(target.obj, |d| d.set(b"Kids".to_vec(), Object::Array(list)))?; + Ok(()) +} + +/// Rename the appearance state `old` to `new` in the widget dictionary `d` (`/AP` `/N`, `/D`, +/// `/R`). A shared `/AP` or state dictionary is copied into `d` rather than renamed for its other +/// users. Whether `/N` had the state: when it didn't, nothing is changed. +fn rename_state(doc: &Document, d: &mut Dict, old: &str, new: &str) -> bool { + let (old, new) = (crate::name_bytes(old), crate::name_bytes(new)); + let Some(mut ap) = d.get(b"AP").and_then(|a| doc.resolve(a).as_dict().cloned()) else { return false }; + let states = |ap: &Dict, key: &[u8]| ap.get(key).and_then(|s| doc.resolve(s).as_dict().cloned()); + if !states(&ap, b"N").is_some_and(|n| n.contains(&old)) { + return false; + } + for key in [&b"N"[..], b"D", b"R"] { + let Some(mut sub) = states(&ap, key) else { continue }; + if let Some(v) = sub.remove(&old) { + sub.set(new.clone(), v); + ap.set(key.to_vec(), Object::Dict(sub)); + } + } + d.set(b"AP".to_vec(), Object::Dict(ap)); + true +} diff --git a/crates/forms/src/author/lifecycle.rs b/crates/forms/src/author/lifecycle.rs new file mode 100644 index 0000000000..f719292a80 --- /dev/null +++ b/crates/forms/src/author/lifecycle.rs @@ -0,0 +1,428 @@ +use super::look::redraw_field; +use super::tree::{add_to_page, attach, clash, detach, ensure_form, free_name, name_parts, top_names}; +use super::{ + Dict, Document, Field, FieldKind, FormError, MIN_FIELD_SIDE, NewField, ObjRef, Object, PdfString, Widget, appearance, fields, flags, invalid, + page_refs, rgb, +}; + +fn widget_dict(page: ObjRef, rect: [f64; 4]) -> Dict { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("Annot")); + d.set(b"Subtype".to_vec(), Object::name("Widget")); + d.set(b"Rect".to_vec(), Object::Array(rect.iter().map(|v| Object::Real(*v)).collect())); + d.set(b"P".to_vec(), Object::Ref(page)); + d.set(b"F".to_vec(), Object::Int(4)); + let mut mk = Dict::new(); + mk.set(b"BC".to_vec(), rgb([0.55, 0.55, 0.55])); + mk.set(b"BG".to_vec(), rgb([1.0, 1.0, 1.0])); + d.set(b"MK".to_vec(), Object::Dict(mk)); + let mut bs = Dict::new(); + bs.set(b"W".to_vec(), Object::Int(1)); + bs.set(b"S".to_vec(), Object::name("S")); + d.set(b"BS".to_vec(), Object::Dict(bs)); + d +} + +/// Add a field on `page` (0-based) in `rect` (user space). Returns its full name. +pub fn add_field(doc: &mut Document, page: usize, rect: [f64; 4], kind: &NewField, name: Option<&str>) -> Result { + let pages = page_refs(doc); + let page_ref = *pages.get(page).ok_or_else(|| FormError::Invalid(format!("page {} does not exist", page + 1)))?; + let rect = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])]; + if !rect.iter().all(|v| v.is_finite()) || rect[2] - rect[0] < MIN_FIELD_SIDE || rect[3] - rect[1] < MIN_FIELD_SIDE { + return invalid("the field is too small"); + } + let existing = fields(doc); + // A radio button names its group: a new group takes that name. + let name = match (name, kind) { + (None, NewField::Radio { group: Some(g), .. }) => Some(g.as_str()), + _ => name, + }; + let name = match name.map(str::trim) { + Some(n) if !n.is_empty() => n.to_string(), + _ => free_name(&existing, &top_names(doc), kind.base_name()), + }; + let parts = name_parts(&name)?; + let radio_group = match kind { + NewField::Radio { group, .. } => existing.iter().find(|f| f.kind == FieldKind::Radio && Some(&f.name) == group.as_ref()).cloned(), + _ => None, + }; + if radio_group.is_none() + && let Some(why) = existing.iter().find_map(|f| clash(&name, &f.name)) + { + return invalid(why); + } + let af = ensure_form(doc)?; + let mut w = widget_dict(page_ref, rect); + let field_name = match kind { + NewField::Radio { group: Some(g), .. } if radio_group.is_some() => g.clone(), + _ => name.clone(), + }; + match kind { + NewField::Text { multiline } => { + w.set(b"FT".to_vec(), Object::name("Tx")); + // Auto size (0): a fixed 12 pt is clipped in a box shorter than that (#1888). + w.set(b"DA".to_vec(), PdfString::literal(b"/Helv 0 Tf 0 g".to_vec())); + if *multiline { + w.set(b"Ff".to_vec(), Object::Int(flags::MULTILINE as i64)); + } + } + NewField::Date => { + w.set(b"FT".to_vec(), Object::name("Tx")); + w.set(b"DA".to_vec(), PdfString::literal(b"/Helv 0 Tf 0 g".to_vec())); + // Acrobat's format and keystroke actions for mm/dd/yyyy (run by its JavaScript engine). + let js = |s: &str| { + let mut a = Dict::new(); + a.set(b"S".to_vec(), Object::name("JavaScript")); + a.set(b"JS".to_vec(), PdfString::literal(s.as_bytes().to_vec())); + Object::Dict(a) + }; + let mut aa = Dict::new(); + aa.set(b"F".to_vec(), js("AFDate_FormatEx(\"mm/dd/yyyy\");")); + aa.set(b"K".to_vec(), js("AFDate_KeystrokeEx(\"mm/dd/yyyy\");")); + w.set(b"AA".to_vec(), Object::Dict(aa)); + } + NewField::CheckBox => { + w.set(b"FT".to_vec(), Object::name("Btn")); + w.set(b"V".to_vec(), Object::name("Off")); + w.set(b"AS".to_vec(), Object::name("Off")); + w.set(b"DA".to_vec(), PdfString::literal(b"/ZaDb 0 Tf 0 g".to_vec())); + } + NewField::Radio { export, .. } => { + w.set(b"AS".to_vec(), Object::name("Off")); + if export.trim().is_empty() || export == "Off" { + return invalid("a radio button needs an export value other than Off"); + } + } + NewField::Combo { options, editable } | NewField::List { options, multi: editable } => { + w.set(b"FT".to_vec(), Object::name("Ch")); + w.set(b"DA".to_vec(), PdfString::literal(b"/Helv 12 Tf 0 g".to_vec())); + let combo = matches!(kind, NewField::Combo { .. }); + let mut ff = 0; + if combo { + ff |= flags::COMBO; + if *editable { + ff |= flags::EDIT; + } + } else if *editable { + ff |= flags::MULTI_SELECT; + } + w.set(b"Ff".to_vec(), Object::Int(ff as i64)); + w.set(b"Opt".to_vec(), Object::Array(options.iter().map(|o| Object::String(PdfString::text(o))).collect())); + } + NewField::Button { caption } => { + w.set(b"FT".to_vec(), Object::name("Btn")); + w.set(b"Ff".to_vec(), Object::Int(flags::PUSH_BUTTON as i64)); + w.set(b"DA".to_vec(), PdfString::literal(b"/Helv 0 Tf 0 g".to_vec())); + let mut mk = w.get(b"MK").and_then(|m| m.as_dict()).cloned().unwrap_or_default(); + mk.set(b"BG".to_vec(), rgb([0.86, 0.86, 0.86])); + mk.set(b"CA".to_vec(), PdfString::text(caption)); + w.set(b"MK".to_vec(), Object::Dict(mk)); + } + NewField::Image => { + w.set(b"FT".to_vec(), Object::name("Btn")); + w.set(b"Ff".to_vec(), Object::Int(flags::PUSH_BUTTON as i64)); + w.set(b"DA".to_vec(), PdfString::literal(b"/Helv 0 Tf 0 g".to_vec())); + let mut mk = w.get(b"MK").and_then(|m| m.as_dict()).cloned().unwrap_or_default(); + // Icon only, no border or fill: the picture is the field. + mk.remove(b"BC"); + mk.remove(b"BG"); + mk.set(b"TP".to_vec(), Object::Int(1)); + w.set(b"MK".to_vec(), Object::Dict(mk)); + let mut a = Dict::new(); + a.set(b"S".to_vec(), Object::name("JavaScript")); + a.set(b"JS".to_vec(), PdfString::literal(b"event.target.buttonImportIcon();".to_vec())); + w.set(b"A".to_vec(), Object::Dict(a)); + } + NewField::Signature => { + w.set(b"FT".to_vec(), Object::name("Sig")); + } + } + let widget = doc.add(Object::Dict(w)); + match kind { + NewField::Radio { export, .. } => { + let group = match &radio_group { + Some(g) => g.obj, + None => { + let mut g = Dict::new(); + g.set(b"FT".to_vec(), Object::name("Btn")); + g.set(b"Ff".to_vec(), Object::Int((flags::RADIO | flags::NO_TOGGLE_TO_OFF) as i64)); + g.set(b"V".to_vec(), Object::name("Off")); + g.set(b"DA".to_vec(), PdfString::literal(b"/ZaDb 0 Tf 0 g".to_vec())); + g.set(b"Kids".to_vec(), Object::Array(Vec::new())); + let g = doc.add(Object::Dict(g)); + attach(doc, af, g, &parts)?; + g + } + }; + doc.update_dict(widget, |d| d.set(b"Parent".to_vec(), Object::Ref(group)))?; + doc.update_dict(group, |d| { + let mut kids = d.get(b"Kids").and_then(|k| k.as_array().cloned()).unwrap_or_default(); + kids.push(Object::Ref(widget)); + d.set(b"Kids".to_vec(), Object::Array(kids)); + })?; + let w = Widget { + obj: widget, + page: Some(page), + rect, + on_state: Some(export.clone()), + state: Some("Off".into()), + tab: usize::MAX, + locked: false, + hidden: false, + read_only: false, + rotation: 0, + caption: None, + list: None, + round: true, + }; + let ap = appearance::check_box_states(doc, &w, FieldKind::Radio, export, "/ZaDb 0 Tf 0 g"); + doc.update_dict(widget, |d| d.set(b"AP".to_vec(), Object::Dict(ap)))?; + } + _ => attach(doc, af, widget, &parts)?, + } + add_to_page(doc, page_ref, widget)?; + redraw_field(doc, &field_name)?; + Ok(field_name) +} + +/// Delete a field: its widgets leave their pages and the field leaves the form. +pub fn delete_field(doc: &mut Document, name: &str) -> Result<(), FormError> { + let f = fields(doc).into_iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?; + delete_found_field(doc, f) +} + +/// [`delete_field`] for the field object `obj`, when separate fields share one name. +pub fn delete_field_object(doc: &mut Document, obj: ObjRef) -> Result<(), FormError> { + let f = fields(doc).into_iter().find(|f| f.obj == obj).ok_or_else(|| FormError::NoSuchField(format!("{obj:?}")))?; + delete_found_field(doc, f) +} + +/// Delete widget `widget` (an index into the field's widgets) of the field `name`, keeping the +/// field and its other widgets: one radio button of a group, or one copy of a duplicated field, +/// as Acrobat's Prepare Form deletes the selected one (#4609). A field with one widget is deleted +/// whole. A per-widget `/Opt` entry goes with its widget, and a check box or radio group that was +/// on through the deleted widget alone turns off. +pub fn delete_widget(doc: &mut Document, name: &str, widget: usize) -> Result<(), FormError> { + let f = fields(doc).into_iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?; + let Some(w) = f.widgets.get(widget).cloned() else { + return invalid(format!("{name} has no widget {}", widget.saturating_add(1))); + }; + // A field merged with its widget is that widget's only one. + if f.widgets.len() < 2 || w.obj == f.obj { + return delete_found_field(doc, f); + } + let per_widget_opt = matches!(f.kind, FieldKind::CheckBox | FieldKind::Radio) && f.options.len() == f.widgets.len(); + // Turn the group off when no button left *shows* the deleted one's state: a twin with the same + // on-state name that is currently off does not keep the choice visible. + let on_only_here = w.on_state.as_deref().is_some_and(|on| { + f.value.iter().any(|v| v == on) && !f.widgets.iter().enumerate().any(|(i, o)| i != widget && o.state.as_deref() == Some(on)) + }); + drop_from_pages(doc, &std::iter::once(w.obj).collect())?; + detach(doc, w.obj)?; + drop_from_calculation_order(doc, |r| r == w.obj)?; + let mut d = doc.get(f.obj).as_dict().cloned().unwrap_or_default(); + let mut changed = false; + // The field's own `/Kids` is what made the widget its: `detach` follows the widget's + // `/Parent`, which a damaged file can leave out or point elsewhere, so the field would keep + // a widget no page shows. + if let Some(kids) = d.get(b"Kids").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()) + && kids.iter().any(|k| k.as_ref() == Some(w.obj)) + { + d.set(b"Kids".to_vec(), Object::Array(kids.into_iter().filter(|k| k.as_ref() != Some(w.obj)).collect())); + changed = true; + } + if per_widget_opt + && let Some(opt) = d.get(b"Opt").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()) + && opt.len() == f.widgets.len() + { + let kept: Vec = opt.into_iter().enumerate().filter(|(i, _)| *i != widget).map(|(_, o)| o).collect(); + d.set(b"Opt".to_vec(), Object::Array(kept)); + changed = true; + } + if on_only_here && d.get(b"V").is_some() { + d.set(b"V".to_vec(), Object::name("Off")); + changed = true; + } + // The default (`/DV`, what Reset puts back) can't name a state no button has any more. + let dv_only_here = w.on_state.as_deref().is_some_and(|on| { + d.get(b"DV").map(|v| doc.resolve(v)).and_then(|v| v.as_name().map(<[u8]>::to_vec)).is_some_and(|dv| dv == on.as_bytes()) + && !f.widgets.iter().enumerate().any(|(i, o)| i != widget && o.on_state.as_deref() == Some(on)) + }); + if dv_only_here { + d.set(b"DV".to_vec(), Object::name("Off")); + changed = true; + } + if changed { + doc.set(f.obj, Object::Dict(d)); + } + Ok(()) +} + +fn delete_found_field(doc: &mut Document, f: Field) -> Result<(), FormError> { + let widgets: std::collections::HashSet = f.widgets.iter().map(|w| w.obj).collect(); + drop_from_pages(doc, &widgets)?; + detach(doc, f.obj)?; + // Out of the calculation order too: a `/CO` entry would keep the field, its value and its + // widgets reachable for a full save. + drop_from_calculation_order(doc, |r| r == f.obj || widgets.contains(&r)) +} + +/// Take the annotations `widgets` off every page's `/Annots`, held directly or by reference. +fn drop_from_pages(doc: &mut Document, widgets: &std::collections::HashSet) -> Result<(), FormError> { + for p in page_refs(doc) { + let Some(a) = doc.get(p).as_dict().and_then(|d| d.get(b"Annots").cloned()) else { continue }; + let list = doc.resolve(&a).as_array().cloned().unwrap_or_default(); + let kept: Vec = list.iter().filter(|o| !o.as_ref().is_some_and(|r| widgets.contains(&r))).cloned().collect(); + if kept.len() != list.len() { + match a.as_ref() { + Some(r) if doc.get(r).as_array().is_some() => doc.set(r, Object::Array(kept)), + _ => doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array(kept)))?, + } + } + } + Ok(()) +} + +/// Remove the `/CO` entries `gone` matches, whether the form and the array are held directly or +/// by reference. +pub(super) fn drop_from_calculation_order(doc: &mut Document, gone: impl Fn(ObjRef) -> bool) -> Result<(), FormError> { + let Some(root) = doc.root() else { return Ok(()) }; + let Some(af_obj) = doc.get(root).as_dict().and_then(|d| d.get(b"AcroForm").cloned()) else { return Ok(()) }; + let Some(mut af) = doc.resolve(&af_obj).as_dict().cloned() else { return Ok(()) }; + let Some(co_obj) = af.get(b"CO").cloned() else { return Ok(()) }; + let co = doc.resolve(&co_obj).as_array().cloned().unwrap_or_default(); + let kept: Vec = co.iter().filter(|o| !o.as_ref().is_some_and(&gone)).cloned().collect(); + if kept.len() == co.len() { + return Ok(()); + } + match (co_obj.as_ref(), af_obj.as_ref()) { + (Some(r), _) if doc.get(r).as_array().is_some() => doc.set(r, Object::Array(kept)), + (_, Some(af_ref)) => doc.update_dict(af_ref, |d| d.set(b"CO".to_vec(), Object::Array(kept)))?, + (_, None) => { + af.set(b"CO".to_vec(), Object::Array(kept)); + doc.update_dict(root, |d| d.set(b"AcroForm".to_vec(), Object::Dict(af)))?; + } + } + Ok(()) +} + +/// Widget-only keys: they stay with each widget when a merged field is split. +const WIDGET_KEYS: [&[u8]; 13] = [b"Type", b"Subtype", b"Rect", b"P", b"AP", b"AS", b"MK", b"F", b"BS", b"Border", b"H", b"StructParent", b"A"]; + +/// The `/AA` triggers of a widget annotation (ISO 32000-2 §12.6.3, Table 197). The rest (`K`, +/// `F`, `V`, `C`, Table 199) belong to the field. +const WIDGET_TRIGGERS: [&[u8]; 10] = [b"E", b"X", b"D", b"U", b"Fo", b"Bl", b"PO", b"PC", b"PV", b"PI"]; + +/// Split additional-actions dictionary `aa` into the widget's triggers and the field's. +/// An empty half is `None`. +pub(super) fn split_triggers(aa: &Dict) -> (Option, Option) { + let (mut widget, mut field) = (Dict::new(), Dict::new()); + for (k, v) in aa.iter() { + let to = if WIDGET_TRIGGERS.contains(&k.as_slice()) { &mut widget } else { &mut field }; + to.set(k.clone(), v.clone()); + } + let some = |d: Dict| (!d.is_empty()).then_some(d); + (some(widget), some(field)) +} + +/// Duplicate a field onto other pages (Acrobat: right-click a field ▸ Duplicate): each page +/// gets a widget at the same place, belonging to the same field, so they share one value. +/// A field that is its own widget is first split into a field and a widget kid. Returns how +/// many widgets were added (pages that already have one are skipped). +pub fn duplicate_field(doc: &mut Document, name: &str, pages: &[usize]) -> Result { + let f = fields(doc).into_iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?; + let model = f.widgets.first().cloned().ok_or_else(|| FormError::Invalid(format!("{name} has no widget")))?; + let refs = page_refs(doc); + for p in pages { + if *p >= refs.len() { + return invalid(format!("page {} does not exist", p + 1)); + } + } + let have: std::collections::HashSet = f.widgets.iter().filter_map(|w| w.page).collect(); + let targets: Vec = pages.iter().copied().filter(|p| !have.contains(p)).collect(); + if targets.is_empty() { + return Ok(0); + } + // A merged field/widget becomes a field with one widget kid. + let model_ref = if model.obj == f.obj { split_merged(doc, &f, &model)? } else { model.obj }; + let template = doc.get(model_ref).as_dict().cloned().unwrap_or_default(); + let mut added = Vec::new(); + for p in &targets { + let pr = refs[*p]; + let mut w = template.clone(); + w.set(b"P".to_vec(), Object::Ref(pr)); + w.set(b"Parent".to_vec(), Object::Ref(f.obj)); + w.remove(b"StructParent"); + let wr = doc.add(Object::Dict(w)); + let annots = doc.get(pr).as_dict().and_then(|d| d.get(b"Annots").cloned()); + match annots { + Some(Object::Ref(ar)) => { + let mut a = doc.get(ar).as_array().cloned().unwrap_or_default(); + a.push(Object::Ref(wr)); + doc.set(ar, Object::Array(a)); + } + other => { + let mut a = other.and_then(|o| o.as_array().cloned()).unwrap_or_default(); + a.push(Object::Ref(wr)); + doc.update_dict(pr, |d| d.set(b"Annots".to_vec(), Object::Array(a)))?; + } + } + added.push(wr); + } + doc.update_dict(f.obj, |d| { + let mut kids = d.get(b"Kids").and_then(|k| k.as_array().cloned()).unwrap_or_default(); + kids.extend(added.iter().map(|r| Object::Ref(*r))); + d.set(b"Kids".to_vec(), Object::Array(kids)); + })?; + redraw_field(doc, name)?; + Ok(added.len()) +} + +/// Split field `f`, which is also its only widget `model`, into a field and one widget kid that +/// takes the widget's keys and its place in the page's `/Annots`. Returns the kid. +pub(super) fn split_merged(doc: &mut Document, f: &Field, model: &Widget) -> Result { + let refs = page_refs(doc); + let d = doc.get(f.obj).as_dict().cloned().unwrap_or_default(); + let mut kid = Dict::new(); + for k in WIDGET_KEYS { + if let Some(v) = d.get(k) { + kid.set(k.to_vec(), v.clone()); + } + } + // The widget's own triggers go with it; the field's stay. + let aa = d.get(b"AA").and_then(|a| doc.resolve(a).as_dict().cloned()); + let (widget_aa, field_aa) = aa.as_ref().map(split_triggers).unwrap_or_default(); + if let Some(w) = widget_aa { + kid.set(b"AA".to_vec(), Object::Dict(w)); + } + kid.set(b"Parent".to_vec(), Object::Ref(f.obj)); + let kr = doc.add(Object::Dict(kid)); + doc.update_dict(f.obj, |d| { + for k in WIDGET_KEYS { + d.remove(k); + } + if aa.is_some() { + match field_aa { + Some(fa) => d.set(b"AA".to_vec(), Object::Dict(fa)), + None => { + d.remove(b"AA"); + } + } + } + d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(kr)])); + })?; + // The page lists the widget now, not the field. + if let Some(&pr) = model.page.and_then(|p| refs.get(p)) { + let annots = doc.get(pr).as_dict().and_then(|d| d.get(b"Annots").cloned()); + let swap = |a: Vec| -> Vec { a.into_iter().map(|o| if o.as_ref() == Some(f.obj) { Object::Ref(kr) } else { o }).collect() }; + match annots { + Some(Object::Ref(ar)) => { + let a = doc.get(ar).as_array().cloned().unwrap_or_default(); + doc.set(ar, Object::Array(swap(a))); + } + Some(Object::Array(a)) => doc.update_dict(pr, |d| d.set(b"Annots".to_vec(), Object::Array(swap(a))))?, + _ => {} + } + } + Ok(kr) +} diff --git a/crates/forms/src/author/look.rs b/crates/forms/src/author/look.rs new file mode 100644 index 0000000000..23661ed70a --- /dev/null +++ b/crates/forms/src/author/look.rs @@ -0,0 +1,473 @@ +use super::{Dict, Document, Field, FieldKind, FormError, ObjRef, Object, Stream, Widget, appearance, fields, invalid, rgb}; + +/// The Appearance tab: borders, colours and text. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Look { + pub border: Option<[f64; 3]>, + pub fill: Option<[f64; 3]>, + /// Line thickness in points (Acrobat: thin 1, medium 2, thick 3). + pub width: f64, + pub style: BorderStyle, + pub text: [f64; 3], + pub font: FieldFont, +} + +/// A partial Appearance-tab edit. Outer `None` keeps a property; `Some(None)` removes a +/// border or fill colour. The patch is applied to each widget's own dictionaries. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct LookPatch { + pub border: Option>, + pub fill: Option>, + pub width: Option, + pub style: Option, + pub text: Option<[f64; 3]>, + pub font: Option, +} + +impl LookPatch { + pub(super) fn validate(&self) -> Result<(), FormError> { + for c in self.border.flatten().into_iter().chain(self.fill.flatten()).chain(self.text) { + if !c.iter().all(|v| v.is_finite() && (0.0..=1.0).contains(v)) { + return invalid("appearance colours must be finite RGB values between 0 and 1"); + } + } + if self.width.is_some_and(|v| !v.is_finite() || !(0.0..=12.0).contains(&v)) { + return invalid("border width must be between 0 and 12 points"); + } + Ok(()) + } +} + +/// Rewrite only the selected operators in place: the last `Tf` (font and size) and the last +/// fill colour (`g`, `rg` or `k`), appending one when the string has none. Everything else (a +/// custom font resource, colour space or other graphics state) is kept, and repeated edits don't +/// grow the string. +pub(super) fn patch_da(da: &str, size: Option, patch: &LookPatch) -> String { + if patch.font.is_none() && size.is_none() && patch.text.is_none() { + return da.to_string(); + } + let mut toks: Vec = da.split_whitespace().map(str::to_string).collect(); + let numeric = |s: &str| s.parse::().is_ok(); + if patch.font.is_some() || size.is_some() { + let old = appearance::parse_da(da); + let font = patch.font.map_or_else(|| old.font.clone(), |f| f.resource().to_string()); + // `parse_da` reads sizes up to 300 points; keep the same bound when writing. + let size = appearance::fmt(size.unwrap_or(old.size).clamp(0.0, 300.0)); + let at = toks.iter().enumerate().rev().find(|(i, t)| t.as_str() == "Tf" && *i >= 2).map(|(i, _)| i); + match at { + Some(i) => { + if let Some(slot) = toks.get_mut(i - 2) { + *slot = format!("/{font}"); + } + if let Some(slot) = toks.get_mut(i - 1) { + *slot = size; + } + } + None => toks.extend([format!("/{font}"), size, "Tf".into()]), + } + } + if let Some(c) = patch.text { + let rgb = [appearance::fmt(c[0]), appearance::fmt(c[1]), appearance::fmt(c[2]), "rg".into()]; + let at = toks.iter().enumerate().rev().find_map(|(i, t)| { + let n = match t.as_str() { + "g" => 1, + "rg" => 3, + "k" => 4, + _ => return None, + }; + let start = i.checked_sub(n)?; + toks.get(start..i).is_some_and(|ops| ops.iter().all(|o| numeric(o))).then_some((start, i)) + }); + match at { + Some((start, end)) => { + toks.splice(start..=end, rgb); + } + None => toks.extend(rgb), + } + } + toks.join(" ") +} + +pub(super) fn patch_widget_look(doc: &mut Document, w: &Widget, patch: &LookPatch) -> Result<(), FormError> { + let touches_mk = patch.border.is_some() || patch.fill.is_some(); + let touches_bs = patch.width.is_some() || patch.style.is_some(); + // A size- or font-only edit changes /DA, not the widget: leave it out of the save. + if !touches_mk && !touches_bs { + return Ok(()); + } + let wd = doc.get(w.obj).as_dict().cloned().ok_or_else(|| FormError::Invalid("the widget is not a dictionary".into()))?; + if touches_mk { + let indirect = wd.get(b"MK").and_then(Object::as_ref); + let mut mk = wd.get(b"MK").map(|m| doc.resolve(m)).and_then(|m| m.as_dict().cloned()).unwrap_or_default(); + for (key, change) in [(b"BC".as_slice(), patch.border), (b"BG".as_slice(), patch.fill)] { + if let Some(change) = change { + match change { + Some(c) => mk.set(key.to_vec(), rgb(c)), + None => { + mk.remove(key); + } + } + } + } + // A shared indirect /MK is updated where it lives, not copied into each widget. + match indirect { + Some(r) => doc.set(r, Object::Dict(mk)), + None => doc.update_dict(w.obj, |d| d.set(b"MK".to_vec(), Object::Dict(mk)))?, + } + } + if touches_bs { + let indirect = wd.get(b"BS").and_then(Object::as_ref); + let mut bs = wd.get(b"BS").map(|b| doc.resolve(b)).and_then(|b| b.as_dict().cloned()).unwrap_or_default(); + if let Some(width) = patch.width { + bs.set(b"W".to_vec(), Object::Real(width)); + } + if let Some(style) = patch.style { + bs.set(b"S".to_vec(), Object::name(style.code())); + if style == BorderStyle::Dashed && !bs.contains(b"D") { + bs.set(b"D".to_vec(), Object::Array(vec![Object::Int(3)])); + } + } + match indirect { + Some(r) => doc.set(r, Object::Dict(bs)), + None => doc.update_dict(w.obj, |d| d.set(b"BS".to_vec(), Object::Dict(bs)))?, + } + } + Ok(()) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BorderStyle { + Solid, + Dashed, + Beveled, + Inset, + Underline, +} + +impl BorderStyle { + pub const ALL: [BorderStyle; 5] = [BorderStyle::Solid, BorderStyle::Dashed, BorderStyle::Beveled, BorderStyle::Inset, BorderStyle::Underline]; + + pub(super) fn code(self) -> &'static str { + match self { + BorderStyle::Solid => "S", + BorderStyle::Dashed => "D", + BorderStyle::Beveled => "B", + BorderStyle::Inset => "I", + BorderStyle::Underline => "U", + } + } + + pub fn label(self) -> &'static str { + match self { + BorderStyle::Solid => "Solid", + BorderStyle::Dashed => "Dashed", + BorderStyle::Beveled => "Beveled", + BorderStyle::Inset => "Inset", + BorderStyle::Underline => "Underline", + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FieldFont { + Helvetica, + Times, + Courier, +} + +impl FieldFont { + pub const ALL: [FieldFont; 3] = [FieldFont::Helvetica, FieldFont::Times, FieldFont::Courier]; + + fn resource(self) -> &'static str { + match self { + FieldFont::Helvetica => "Helv", + FieldFont::Times => "TiRo", + FieldFont::Courier => "Cour", + } + } + + pub fn label(self) -> &'static str { + match self { + FieldFont::Helvetica => "Helvetica", + FieldFont::Times => "Times Roman", + FieldFont::Courier => "Courier", + } + } +} + +/// The mark a check box or radio button shows when on (Field Properties ▸ Options ▸ style). +/// Stored as `/MK /CA`, the ZapfDingbats character Acrobat uses for it; PdfCraft draws the +/// mark as paths, so no symbol font is needed. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CheckStyle { + Check, + Circle, + Cross, + Diamond, + Square, + Star, +} + +impl CheckStyle { + pub const ALL: [CheckStyle; 6] = + [CheckStyle::Check, CheckStyle::Circle, CheckStyle::Cross, CheckStyle::Diamond, CheckStyle::Square, CheckStyle::Star]; + + pub(super) fn code(self) -> &'static str { + match self { + CheckStyle::Check => "4", + CheckStyle::Circle => "l", + CheckStyle::Cross => "8", + CheckStyle::Diamond => "u", + CheckStyle::Square => "n", + CheckStyle::Star => "H", + } + } + + pub fn label(self) -> &'static str { + match self { + CheckStyle::Check => "Check", + CheckStyle::Circle => "Circle", + CheckStyle::Cross => "Cross", + CheckStyle::Diamond => "Diamond", + CheckStyle::Square => "Square", + CheckStyle::Star => "Star", + } + } + + /// A widget's style: its `/MK /CA`, else a check for check boxes and a circle for radios. + pub(crate) fn of_widget(doc: &Document, wd: &Dict, kind: FieldKind) -> Self { + let mk = wd.get(b"MK").and_then(|m| doc.resolve(m).as_dict().cloned()).unwrap_or_default(); + let ca = mk.get(b"CA").and_then(|c| doc.resolve(c).as_string().map(|s| s.to_text())); + CheckStyle::ALL.into_iter().find(|s| ca.as_deref() == Some(s.code())).unwrap_or(if kind == FieldKind::Radio { + CheckStyle::Circle + } else { + CheckStyle::Check + }) + } +} + +/// A check box's or radio button's style (from its first widget). +pub fn check_style(doc: &Document, f: &Field) -> CheckStyle { + let wd = f.widgets.first().and_then(|w| doc.get(w.obj).as_dict().cloned()).unwrap_or_default(); + CheckStyle::of_widget(doc, &wd, f.kind) +} + +fn rgb_of(doc: &Document, o: Option<&Object>) -> Option<[f64; 3]> { + let v: Vec = doc.resolve(o?).as_array()?.iter().filter_map(Object::as_f64).collect(); + match v.len() { + 1 => Some([v[0]; 3]), + 3 => Some([v[0], v[1], v[2]]), + 4 => Some([(1.0 - v[0]) * (1.0 - v[3]), (1.0 - v[1]) * (1.0 - v[3]), (1.0 - v[2]) * (1.0 - v[3])]), + _ => None, + } +} + +/// A field's current look (from its first widget and its default appearance). +pub fn look(doc: &Document, f: &Field) -> Look { + let wd = f.widgets.first().and_then(|w| doc.get(w.obj).as_dict().cloned()).unwrap_or_default(); + let mk = wd.get(b"MK").and_then(|m| doc.resolve(m).as_dict().cloned()).unwrap_or_default(); + let bs = wd.get(b"BS").and_then(|b| doc.resolve(b).as_dict().cloned()).unwrap_or_default(); + let da = wd.get(b"DA").and_then(|o| doc.resolve(o).as_string().map(|s| s.to_text())).unwrap_or_else(|| f.da.clone()); + let parsed = appearance::parse_da(&da); + let nums: Vec = parsed.color.split_whitespace().filter_map(|t| t.parse().ok()).collect(); + let text = match nums.len() { + 1 => [nums[0]; 3], + 3 => [nums[0], nums[1], nums[2]], + 4 => [(1.0 - nums[0]) * (1.0 - nums[3]), (1.0 - nums[1]) * (1.0 - nums[3]), (1.0 - nums[2]) * (1.0 - nums[3])], + _ => [0.0; 3], + }; + Look { + border: rgb_of(doc, mk.get(b"BC")), + fill: rgb_of(doc, mk.get(b"BG")), + width: bs.get(b"W").and_then(|w| doc.resolve(w).as_f64()).unwrap_or(1.0), + style: match bs.name(b"S") { + Some(b"D") => BorderStyle::Dashed, + Some(b"B") => BorderStyle::Beveled, + Some(b"I") => BorderStyle::Inset, + Some(b"U") => BorderStyle::Underline, + _ => BorderStyle::Solid, + }, + text, + font: match parsed.font.as_str() { + "TiRo" | "Times-Roman" => FieldFont::Times, + "Cour" | "Courier" => FieldFont::Courier, + _ => FieldFont::Helvetica, + }, + } +} + +/// A `/DA` string with the given font, size and colour. +pub(super) fn da_string(font: FieldFont, size: f64, c: [f64; 3]) -> String { + let f = crate::appearance::fmt; + format!("/{} {} Tf {} {} {} rg", font.resource(), f(size.clamp(0.0, 100.0)), f(c[0]), f(c[1]), f(c[2])) +} + +/// Regenerate every widget appearance of a field from its current value and settings. A value or +/// button caption its font can't hold is drawn with `?` for those characters, the widget is marked +/// (`pdfcraft_fonts::UNDRAWN_MARK`) and other viewers are asked to redraw the form (see +/// `ask_viewers_to_redraw`), as when a value is set. +pub fn redraw_field(doc: &mut Document, name: &str) -> Result<(), FormError> { + redraw_widgets(doc, name, None) +} + +/// `redraw_field` for the widget at index `only` alone (so a caption-only edit leaves the field's +/// other widgets' appearances as they are), or for every widget when `only` is `None`. Fails with +/// `FormError::NoSuchField` when no field is named `name`. +pub(super) fn redraw_widgets(doc: &mut Document, name: &str, only: Option) -> Result<(), FormError> { + let f = fields(doc).into_iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?; + let mut undrawn = false; + for (index, w) in f.widgets.iter().enumerate() { + if only.is_some_and(|only| only != index) { + continue; + } + let mut missing = None; + let ap = match f.kind { + FieldKind::Text | FieldKind::Combo | FieldKind::List => { + let (s, m) = appearance::field_appearance_drawn(doc, &f, w, &f.value, true); + missing = m; + let r = doc.add(Object::Stream(s)); + let mut d = Dict::new(); + d.set(b"N".to_vec(), Object::Ref(r)); + d + } + FieldKind::CheckBox | FieldKind::Radio => { + let on = w.on_state.clone().unwrap_or_else(|| "Yes".into()); + appearance::check_box_states(doc, w, f.kind, &on, &f.da) + } + FieldKind::PushButton => { + let (s, m) = button_appearance(doc, w, &f.da); + missing = m; + let r = doc.add(Object::Stream(s)); + let mut d = Dict::new(); + d.set(b"N".to_vec(), Object::Ref(r)); + d + } + FieldKind::Signature => { + let s = empty_box(doc, w); + let r = doc.add(Object::Stream(s)); + let mut d = Dict::new(); + d.set(b"N".to_vec(), Object::Ref(r)); + d + } + }; + undrawn |= missing.is_some(); + let existing = crate::merged_appearance(doc, w.obj, &ap); + doc.update_dict(w.obj, |d| { + d.set(b"AP".to_vec(), Object::Dict(existing)); + pdfcraft_fonts::set_undrawn_mark(d, missing); + })?; + } + if undrawn { + crate::ask_viewers_to_redraw(doc)?; + } + Ok(()) +} + +fn frame_only(doc: &Document, w: &Widget) -> (String, crate::appearance::Placement) { + let (page_w, page_h) = ((w.rect[2] - w.rect[0]).max(1.0), (w.rect[3] - w.rect[1]).max(1.0)); + let wobj = doc.get(w.obj); + let wd = wobj.as_dict().cloned().unwrap_or_default(); + let place = crate::appearance::placement(crate::appearance::mk_rotation(doc, &wd), page_w, page_h); + let (width, height) = (place.layout_w, place.layout_h); + let mk = wd.get(b"MK").and_then(|m| m.as_dict()).cloned().unwrap_or_default(); + let col = |k: &[u8]| -> Option { + let v: Vec = mk.get(k)?.as_array()?.iter().filter_map(|x| x.as_f64()).collect(); + (v.len() == 3).then(|| format!("{} {} {}", crate::appearance::fmt(v[0]), crate::appearance::fmt(v[1]), crate::appearance::fmt(v[2]))) + }; + let mut c = String::new(); + if let Some(bg) = col(b"BG") { + c.push_str(&format!("{bg} rg 0 0 {width:.3} {height:.3} re f\n")); + } + if let Some(bc) = col(b"BC") { + c.push_str(&format!("{bc} RG 1 w 0.5 0.5 {:.3} {:.3} re S\n", width - 1.0, height - 1.0)); + } + (c, place) +} + +fn form_stream(place: crate::appearance::Placement, content: Vec, resources: Dict) -> Stream { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Form")); + crate::appearance::set_form_box(&mut d, place); + d.set(b"Resources".to_vec(), Object::Dict(resources)); + Stream::flate(d, &content) +} + +/// A push button: background, border, its icon (`/MK /I`, scaled to fit and centred) and its +/// centred caption (unless the layout is icon only, `/TP 1`). The caption uses the font, size and +/// color its `/DA` asks for (the widget's, else `field_da`, the field's own or inherited one). +/// Also returns the first caption character the font can't draw (drawn as `?`), for the caller to +/// mark and report. +pub(crate) fn button_appearance(doc: &Document, w: &Widget, field_da: &str) -> (Stream, Option) { + let (mut c, place) = frame_only(doc, w); + let (width, height) = (place.layout_w, place.layout_h); + let wobj = doc.get(w.obj); + let mk = wobj.as_dict().and_then(|d| d.get(b"MK")).map(|m| doc.resolve(m)).and_then(|m| m.as_dict().cloned()).unwrap_or_default(); + let caption = mk.get(b"CA").map(|c| doc.resolve(c)).and_then(|c| c.as_string().map(|s| s.to_text())).unwrap_or_default(); + let icon_only = mk.get(b"TP").and_then(|t| doc.resolve(t).as_int()) == Some(1); + let mut content = std::mem::take(&mut c).into_bytes(); + let mut xobjects = Dict::new(); + if let Some(icon) = mk.get(b"I").and_then(Object::as_ref) + && let Object::Stream(s) = &*doc.get(icon) + { + let bb: Vec = s.dict.get(b"BBox").and_then(|b| b.as_array().map(|a| a.iter().filter_map(Object::as_f64).collect())).unwrap_or_default(); + if let [x0, y0, x1, y1] = bb[..] + && x1 > x0 + && y1 > y0 + { + // Proportional, as large as fits (Acrobat's default icon placement), centred. + let k = ((width - 2.0) / (x1 - x0)).min((height - 2.0) / (y1 - y0)).max(0.0); + let (dx, dy) = ((width - (x1 - x0) * k) / 2.0 - x0 * k, (height - (y1 - y0) * k) / 2.0 - y0 * k); + content.extend(format!("q {k:.6} 0 0 {k:.6} {dx:.3} {dy:.3} cm /Icon Do Q\n").bytes()); + xobjects.set(b"Icon".to_vec(), Object::Ref(icon)); + } + } + let mut missing = None; + let mut fonts = Dict::new(); + if !icon_only && !caption.is_empty() { + let da = wobj.as_dict().and_then(|d| d.get(b"DA")).and_then(|o| doc.resolve(o).as_string().map(|s| s.to_text())); + let auto = ((height - 4.0) * 0.6).clamp(4.0, 14.0); + let (ops, name, font, m) = appearance::button_caption(doc, da.as_deref().unwrap_or(field_da), &caption, width, height, auto); + content.extend(ops); + fonts.set(name.into_bytes(), font); + missing = m; + } + let mut res = Dict::new(); + res.set(b"Font".to_vec(), Object::Dict(fonts)); + if !xobjects.is_empty() { + res.set(b"XObject".to_vec(), Object::Dict(xobjects)); + } + (form_stream(place, content, res), missing) +} + +/// Give a push button (an image field) the picture `image` (an image XObject of `px` pixels): +/// it becomes the button's icon (`/MK /I`) and its appearance. +pub fn set_button_icon(doc: &mut Document, name: &str, image: ObjRef, px: (u32, u32)) -> Result<(), FormError> { + let f = fields(doc).into_iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?; + if f.kind != FieldKind::PushButton { + return invalid(format!("{name} is not a button or image field")); + } + // The icon form draws the image in a box of its pixel size (keeping its aspect ratio). + let (w, h) = (px.0.max(1) as f64, px.1.max(1) as f64); + let mut xo = Dict::new(); + xo.set(b"Im0".to_vec(), Object::Ref(image)); + let mut res = Dict::new(); + res.set(b"XObject".to_vec(), Object::Dict(xo)); + let icon = + doc.add(Object::Stream(form_stream(crate::appearance::placement(0, w, h), format!("q {w} 0 0 {h} 0 0 cm /Im0 Do Q").into_bytes(), res))); + for wd in &f.widgets { + let mut mk = + doc.get(wd.obj).as_dict().and_then(|d| d.get(b"MK").map(|m| doc.resolve(m))).and_then(|m| m.as_dict().cloned()).unwrap_or_default(); + mk.set(b"I".to_vec(), Object::Ref(icon)); + if !mk.contains(b"TP") { + mk.set(b"TP".to_vec(), Object::Int(1)); + } + doc.update_dict(wd.obj, |d| d.set(b"MK".to_vec(), Object::Dict(mk)))?; + } + redraw_field(doc, name) +} + +fn empty_box(doc: &Document, w: &Widget) -> Stream { + let (c, place) = frame_only(doc, w); + form_stream(place, c.into_bytes(), Dict::new()) +} diff --git a/crates/forms/src/author/tree.rs b/crates/forms/src/author/tree.rs new file mode 100644 index 0000000000..ce4ee687bb --- /dev/null +++ b/crates/forms/src/author/tree.rs @@ -0,0 +1,398 @@ +use super::{Dict, Document, Field, FormError, ObjRef, Object, PdfString, invalid}; + +/// The AcroForm dictionary's reference, creating the form (with `/Helv` in `/DR`) if needed. +pub(crate) fn ensure_form(doc: &mut Document) -> Result { + let root = doc.root().ok_or(FormError::NoForm)?; + let existing = doc.get(root).as_dict().and_then(|d| d.get(b"AcroForm").cloned()); + let r = match existing { + Some(Object::Ref(r)) if doc.get(r).as_dict().is_some() => r, + other => { + let d = other.and_then(|o| o.as_dict().cloned()).unwrap_or_default(); + let r = doc.add(Object::Dict(d)); + doc.update_dict(root, |c| c.set(b"AcroForm".to_vec(), Object::Ref(r)))?; + r + } + }; + let mut af = doc.get(r).as_dict().cloned().unwrap_or_default(); + if !af.contains(b"Fields") { + af.set(b"Fields".to_vec(), Object::Array(Vec::new())); + } + if !af.contains(b"DA") { + af.set(b"DA".to_vec(), PdfString::literal(b"/Helv 0 Tf 0 g".to_vec())); + } + let (dr, mut fonts) = form_fonts(doc, &af); + let mut added = inline_fonts_as_objects(doc, &mut fonts); + // Acrobat's resource names for the standard fonts its Appearance tab offers. + for (name, base) in [("Helv", "Helvetica"), ("TiRo", "Times-Roman"), ("Cour", "Courier")] { + if fonts.contains(name.as_bytes()) { + continue; + } + let mut f = Dict::new(); + f.set(b"Type".to_vec(), Object::name("Font")); + f.set(b"Subtype".to_vec(), Object::name("Type1")); + f.set(b"BaseFont".to_vec(), Object::name(base)); + f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); + let r = doc.add(Object::Dict(f)); + fonts.set(name.as_bytes().to_vec(), Object::Ref(r)); + added = true; + } + if added { + store_form_fonts(doc, &mut af, dr, fonts); + } + doc.set(r, Object::Dict(af)); + Ok(r) +} + +/// The form dictionary's `/DR` and its `/Font` dictionary, resolved (empty where missing). +fn form_fonts(doc: &Document, af: &Dict) -> (Dict, Dict) { + let dr = af.get(b"DR").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default(); + let fonts = dr.get(b"Font").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default(); + (dr, fonts) +} + +/// Each /DR font its own object: Acrobat, redrawing a text field after it is typed in, shows +/// nothing when the font is a dictionary written inline in /DR (#3441). Fonts written inline +/// (by an earlier save, or another writer) become objects saying the same; a font that is an +/// object already, or an entry that isn't a dictionary, is left alone. Whether any changed. +fn inline_fonts_as_objects(doc: &mut Document, fonts: &mut Dict) -> bool { + let inline: Vec<(Vec, Dict)> = fonts.iter().filter_map(|(k, v)| Some((k.clone(), v.as_dict()?.clone()))).collect(); + let changed = !inline.is_empty(); + for (name, font) in inline { + let r = doc.add(Object::Dict(font)); + fonts.set(name, Object::Ref(r)); + } + changed +} + +/// Write `fonts` back as the `/DR /Font` of the form dictionary `af`. An indirect `/DR` or +/// `/Font` stays the object it is. +fn store_form_fonts(doc: &mut Document, af: &mut Dict, mut dr: Dict, fonts: Dict) { + match dr.get(b"Font").and_then(Object::as_ref) { + Some(fr) => doc.set(fr, Object::Dict(fonts)), + None => dr.set(b"Font".to_vec(), Object::Dict(fonts)), + } + match af.get(b"DR").and_then(Object::as_ref) { + Some(dr_ref) => doc.set(dr_ref, Object::Dict(dr)), + None => af.set(b"DR".to_vec(), Object::Dict(dr)), + } +} + +/// Filling a field: the form's inline `/DR` fonts become objects (see +/// [`inline_fonts_as_objects`]), so a form an earlier version of PdfCraft made works in Acrobat +/// once it has been filled here too (#3441). Only the font storage changes; nothing is added. +/// Not in a form a signature covers: there the file stays as the signer saw it, apart from the +/// value itself. +pub(crate) fn form_fonts_as_objects(doc: &mut Document, all: &[crate::Field]) -> Result<(), FormError> { + let signed = all.iter().any(|f| f.kind == crate::FieldKind::Signature && holds_value(doc, f.obj)); + if signed { + return Ok(()); + } + let Some(root) = doc.root() else { return Ok(()) }; + let Some(entry) = doc.get(root).as_dict().and_then(|c| c.get(b"AcroForm").cloned()) else { return Ok(()) }; + match entry { + Object::Ref(r) => { + let Some(mut af) = doc.get(r).as_dict().cloned() else { return Ok(()) }; + let (dr, mut fonts) = form_fonts(doc, &af); + if inline_fonts_as_objects(doc, &mut fonts) { + store_form_fonts(doc, &mut af, dr, fonts); + doc.set(r, Object::Dict(af)); + } + } + // A form dictionary written directly in the catalog (the catalog's /AcroForm need not be + // an indirect reference) is written back there. + Object::Dict(mut af) => { + let (dr, mut fonts) = form_fonts(doc, &af); + if inline_fonts_as_objects(doc, &mut fonts) { + store_form_fonts(doc, &mut af, dr, fonts); + doc.update_dict(root, |c| c.set(b"AcroForm".to_vec(), Object::Dict(af)))?; + } + } + _ => {} + } + Ok(()) +} + +/// Whether the field `r`, or the nearest ancestor that has one, holds a `/V` (the entry is +/// inheritable, ISO 32000-2 §12.7.4). The `/Parent` chain is followed at most 64 deep, as deep +/// as the field walk goes, so a cyclic chain ends. +fn holds_value(doc: &Document, r: ObjRef) -> bool { + let mut at = r; + for _ in 0..=64 { + let obj = doc.get(at); + let Some(d) = obj.as_dict() else { return false }; + if d.contains(b"V") { + return true; + } + match d.reference(b"Parent") { + Some(p) => at = p, + None => return false, + } + } + false +} + +pub(super) fn add_to_page(doc: &mut Document, page: ObjRef, widget: ObjRef) -> Result<(), FormError> { + let existing = doc.get(page).as_dict().and_then(|d| d.get(b"Annots").cloned()); + match existing.as_ref().and_then(|o| o.as_ref()).filter(|r| doc.get(*r).as_array().is_some()) { + Some(r) => { + let mut a = doc.get(r).as_array().cloned().unwrap_or_default(); + a.push(Object::Ref(widget)); + doc.set(r, Object::Array(a)); + } + None => { + let mut a = existing.and_then(|o| o.as_array().cloned()).unwrap_or_default(); + a.push(Object::Ref(widget)); + doc.update_dict(page, |d| d.set(b"Annots".to_vec(), Object::Array(a)))?; + } + } + Ok(()) +} + +fn add_to_fields(doc: &mut Document, af: ObjRef, field: ObjRef) -> Result<(), FormError> { + let mut d = doc.get(af).as_dict().cloned().unwrap_or_default(); + let mut list = d.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); + list.push(Object::Ref(field)); + d.set(b"Fields".to_vec(), Object::Array(list)); + doc.set(af, Object::Dict(d)); + Ok(()) +} + +/// The most parts a dotted field name may have ("a.b.c" has three). +const MAX_NAME_PARTS: usize = 32; + +/// The field attributes a field inherits from its parents (ISO 32000-2 §12.7.4.1). +const INHERITED_KEYS: [&[u8]; 7] = [b"FT", b"Ff", b"V", b"DV", b"DA", b"Q", b"MaxLen"]; + +/// The parts of a full field name: `a.b` is the field `b` under the parent field `a`, since a +/// field's own (partial) name can't contain a period (ISO 32000-2 §12.7.4.2) (#2696). +pub(super) fn name_parts(name: &str) -> Result, FormError> { + let parts: Vec<&str> = name.split('.').take(MAX_NAME_PARTS.saturating_add(1)).collect(); + if parts.len() > MAX_NAME_PARTS { + return invalid(format!("field names can have at most {MAX_NAME_PARTS} parts")); + } + if parts.iter().any(|p| p.trim().is_empty()) { + return invalid("field names can't be empty, or have an empty part between periods"); + } + Ok(parts) +} + +/// Why the full name `name` can't be used beside the field named `other`, if it can't: the +/// same name, or one inside the other (a field that has a value can't also hold fields). +pub(super) fn clash(name: &str, other: &str) -> Option { + if name == other { + return Some(format!("a field named {name:?} already exists")); + } + let inside = |a: &str, b: &str| a.strip_prefix(b).is_some_and(|rest| rest.starts_with('.')); + (inside(name, other) || inside(other, name)).then(|| format!("{name:?} can't be used beside the field {other:?}")) +} + +/// The array `key` of `holder` (`/Fields` of the form, `/Kids` of a field). +pub(super) fn list_of(doc: &Document, holder: ObjRef, key: &[u8]) -> Vec { + let d = doc.get(holder).as_dict().cloned().unwrap_or_default(); + d.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default() +} + +/// Add `field` to `parent`'s `/Kids`, or to the form's `/Fields` at the top. +fn link(doc: &mut Document, af: ObjRef, parent: Option, field: ObjRef) -> Result<(), FormError> { + let Some(p) = parent else { return add_to_fields(doc, af, field) }; + let mut kids = list_of(doc, p, b"Kids"); + kids.push(Object::Ref(field)); + doc.update_dict(p, |d| d.set(b"Kids".to_vec(), Object::Array(kids)))?; + Ok(()) +} + +/// The node named `name` (its own `/T`) directly under `parent`, or at the top of the form. +pub(super) fn child_named(doc: &Document, af: ObjRef, parent: Option, name: &str) -> Option { + let list = match parent { + Some(p) => list_of(doc, p, b"Kids"), + None => list_of(doc, af, b"Fields"), + }; + list.iter() + .filter_map(|o| o.as_ref()) + .find(|r| doc.get(*r).as_dict().and_then(|d| d.get(b"T").cloned()).and_then(|t| crate::text_of(&doc.resolve(&t))).as_deref() == Some(name)) +} + +/// The values a parent field passes to the fields under it that can't be turned off by giving +/// the field a neutral one of its own, as `/Ff` and `/Q` can. +const PASSED_VALUES: [&[u8]; 3] = [b"V", b"DV", b"MaxLen"]; + +/// Whether `field` may go in the field tree under the full name `parts`, changing nothing. The +/// field list holds only terminal fields, so a parent left empty by a move isn't in it: a node +/// of the same name there is found here. +/// `inherited` is what the field will hold as its own before it moves (see [`inherited`]). +pub(super) fn check_place(doc: &Document, af: ObjRef, field: ObjRef, parts: &[&str], inherited: &Dict) -> Result<(), FormError> { + let Some((own, parents)) = parts.split_last() else { return invalid("field names can't be empty") }; + let mut owned = doc.get(field).as_dict().cloned().unwrap_or_default(); + for (k, v) in inherited.iter() { + owned.set(k.to_vec(), v.clone()); + } + let mut at: Option = None; + let mut whole_path = true; + for part in parents { + let Some(r) = child_named(doc, af, at, part) else { + whole_path = false; + break; + }; + // A value, reset value or length limit held by a parent would pass to this field. + let held = doc.get(r).as_dict().is_some_and(|d| PASSED_VALUES.iter().any(|k| d.contains(k) && !owned.contains(k))); + if held { + return invalid(format!("{part:?} holds a value for the fields under it, so {:?} can't be put there", parts.join("."))); + } + at = Some(r); + } + // A field being moved is still in the tree here, but under another full name (a move to its + // own name changes nothing and doesn't get here), so a node found is another. + if whole_path && child_named(doc, af, at, own).is_some() { + return invalid(format!("a field named {:?} already exists", parts.join("."))); + } + Ok(()) +} + +/// Put `field` in the field tree under the full name `parts`: its own `/T` is the last part, +/// and the parts before it name parent fields, reused where the tree has them and made where +/// not (Acrobat makes `a.b` a field `b` under a field `a`). +pub(super) fn attach(doc: &mut Document, af: ObjRef, field: ObjRef, parts: &[&str]) -> Result<(), FormError> { + let Some((own, parents)) = parts.split_last() else { return invalid("field names can't be empty") }; + check_place(doc, af, field, parts, &Dict::new())?; + let mut parent: Option = None; + let mut inherits = false; + for part in parents { + let next = match child_named(doc, af, parent, part) { + Some(r) => { + inherits |= doc.get(r).as_dict().is_some_and(|d| d.contains(b"Ff") || d.contains(b"Q")); + r + } + None => { + let mut d = Dict::new(); + d.set(b"T".to_vec(), PdfString::text(part)); + d.set(b"Kids".to_vec(), Object::Array(Vec::new())); + if let Some(p) = parent { + d.set(b"Parent".to_vec(), Object::Ref(p)); + } + let r = doc.add(Object::Dict(d)); + link(doc, af, parent, r)?; + r + } + }; + parent = Some(next); + } + doc.update_dict(field, |d| { + d.set(b"T".to_vec(), PdfString::text(own)); + match parent { + Some(p) => d.set(b"Parent".to_vec(), Object::Ref(p)), + None => { + d.remove(b"Parent"); + } + } + // An existing parent's flags and alignment would otherwise pass to this field. + if inherits { + for k in [&b"Ff"[..], b"Q"] { + if !d.contains(k) { + d.set(k.to_vec(), Object::Int(0)); + } + } + } + })?; + link(doc, af, parent, field) +} + +/// What `field` inherits from its parents and doesn't hold itself, changing nothing. +pub(super) fn inherited(doc: &Document, field: ObjRef) -> Dict { + let own = doc.get(field).as_dict().cloned().unwrap_or_default(); + let mut add = Dict::new(); + let mut seen = std::collections::HashSet::new(); + let mut parent = own.reference(b"Parent"); + while let Some(p) = parent { + if seen.len() > 64 || !seen.insert(p) { + break; + } + let Some(d) = doc.get(p).as_dict().cloned() else { break }; + for k in INHERITED_KEYS { + if let Some(v) = d.get(k).filter(|_| !own.contains(k) && !add.contains(k)) { + add.set(k.to_vec(), v.clone()); + } + } + parent = d.reference(b"Parent"); + } + add +} + +/// Whether an ancestor of `field` sets the inheritable `key`, which `field` would take on without +/// its own entry. +pub(crate) fn ancestor_sets(doc: &Document, field: ObjRef, key: &[u8]) -> bool { + let mut seen = std::collections::HashSet::new(); + let mut parent = doc.get(field).as_dict().and_then(|d| d.reference(b"Parent")); + while let Some(p) = parent { + if seen.len() > 64 || !seen.insert(p) { + return false; + } + let Some(d) = doc.get(p).as_dict().cloned() else { return false }; + if d.contains(key) { + return true; + } + parent = d.reference(b"Parent"); + } + false +} + +/// Copy onto `field` what it inherits (`add`, from [`inherited`]), so it keeps it when it moves. +pub(super) fn own_inherited(doc: &mut Document, field: ObjRef, add: &Dict) -> Result<(), FormError> { + if add.iter().next().is_some() { + doc.update_dict(field, |d| { + for (k, v) in add.iter() { + d.set(k.to_vec(), v.clone()); + } + })?; + } + Ok(()) +} + +/// Take `field` out of the field tree: from its parent's `/Kids`, or from `/Fields`. +pub(super) fn detach(doc: &mut Document, field: ObjRef) -> Result<(), FormError> { + let parent = doc.get(field).as_dict().and_then(|d| d.reference(b"Parent")); + let remove_from = |doc: &mut Document, holder: ObjRef, key: &[u8]| -> Result<(), FormError> { + let mut d = doc.get(holder).as_dict().cloned().unwrap_or_default(); + let list = d.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); + let kept: Vec = list.into_iter().filter(|o| o.as_ref() != Some(field)).collect(); + d.set(key.to_vec(), Object::Array(kept)); + doc.set(holder, Object::Dict(d)); + Ok(()) + }; + match parent { + Some(p) => remove_from(doc, p, b"Kids")?, + None => { + let root = doc.root().ok_or(FormError::NoForm)?; + match doc.get(root).as_dict().and_then(|d| d.get(b"AcroForm").cloned()) { + Some(Object::Ref(af)) => remove_from(doc, af, b"Fields")?, + // A form dictionary held directly in the catalog. + Some(Object::Dict(mut af)) => { + let list = af.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); + let kept: Vec = list.into_iter().filter(|o| o.as_ref() != Some(field)).collect(); + af.set(b"Fields".to_vec(), Object::Array(kept)); + doc.update_dict(root, |d| d.set(b"AcroForm".to_vec(), Object::Dict(af)))?; + } + _ => {} + } + } + } + Ok(()) +} + +/// The first free "N" name: not a field's, and not held by a node at the top of the field +/// tree (`held`), such as a parent field left empty by a move, which isn't in `existing`. +pub(super) fn free_name(existing: &[Field], held: &[String], base: &str) -> String { + (1..=u64::MAX) + .map(|n| format!("{base}{n}")) + .find(|c| !held.contains(c) && !existing.iter().any(|f| f.name == *c || f.name.starts_with(&format!("{c}.")))) + .unwrap_or_else(|| base.to_string()) +} + +/// The own names (`/T`) of the nodes in the form's `/Fields`, changing nothing. +pub(super) fn top_names(doc: &Document) -> Vec { + let Some(root) = doc.root() else { return Vec::new() }; + let Some(af) = doc.get(root).as_dict().and_then(|d| d.get(b"AcroForm").map(|o| doc.resolve(o))).and_then(|o| o.as_dict().cloned()) else { + return Vec::new(); + }; + let list = af.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); + list.iter().filter_map(|o| doc.resolve(o).as_dict().and_then(|d| d.get(b"T").cloned())).filter_map(|t| crate::text_of(&doc.resolve(&t))).collect() +} diff --git a/crates/forms/src/calc_order.rs b/crates/forms/src/calc_order.rs index 4e7f0309e9..62c16ce67d 100644 --- a/crates/forms/src/calc_order.rs +++ b/crates/forms/src/calc_order.rs @@ -17,18 +17,24 @@ const MAX_LINKS: usize = 1 << 20; pub struct CalculatedField { /// The field's full name. pub name: String, + /// The field object. Two calculated fields can share a name in a malformed form; this tells + /// them apart. + pub obj: ObjRef, /// The fields its formula reads, each once, as the formula names them: a name also stands for /// the fields under it. A script's names built at run time (`getField("row" + i)`) aren't here. pub reads: Vec, /// `/CO` lists it. A field it leaves out calculates after the listed ones. pub listed: bool, - /// What is wrong with its place in the order. + /// What is wrong with its formula or its place in the order. pub issues: Vec, } -/// Something wrong with a calculated field's place in the calculation order. +/// Something wrong with a calculated field's formula or its place in the calculation order. #[derive(Clone, Debug, PartialEq)] pub enum CalculationIssue { + /// It reads `field`, which the form doesn't have (a field deleted, renamed outside PdfCraft, + /// or mistyped), so the formula gets no value there. + MissingField { field: String }, /// It reads `field`, which calculates after it, so it uses that field's previous value. RunsTooEarly { field: String }, /// Its formula reads its own result: it reads `fields`, which read it back (itself, when it @@ -37,6 +43,9 @@ pub enum CalculationIssue { /// `/CO` leaves it out. PdfCraft calculates it after the listed fields, but other PDF viewers /// may not calculate it at all. NotInOrder, + /// Its script also reads fields by names it builds at run time (`getField("row" + i)`). Those + /// aren't in `reads`, so the order isn't checked against them. + BuiltNames, } /// A form's calculation order and what is wrong with it. @@ -152,11 +161,14 @@ pub fn calculation_order(doc: &Document, fields: &[Field]) -> CalculationOrder { .collect(); let links = links(calculated.iter().map(|(f, _)| f.name.as_str()), &reads); let component = components(&links); + let mut all: Vec<&str> = fields.iter().map(|f| f.name.as_str()).collect(); + all.sort_unstable(); let name = |k: usize| calculated.get(k).map(|(f, _)| f.name.clone()).unwrap_or_default(); let mut out = Vec::with_capacity(calculated.len()); for (k, ((f, listed), reads)) in calculated.iter().zip(reads).enumerate() { let own = component.get(k).copied(); - let mut issues = Vec::new(); + let mut issues: Vec = + reads.iter().filter(|name| !found(&all, name)).map(|name| CalculationIssue::MissingField { field: name.clone() }).collect(); let read = links.get(k).map(Vec::as_slice).unwrap_or_default(); for &j in read { if j > k && component.get(j).copied() != own { @@ -172,12 +184,25 @@ pub fn calculation_order(doc: &Document, fields: &[Field]) -> CalculationOrder { if !listed { issues.push(CalculationIssue::NotInOrder); } - out.push(CalculatedField { name: f.name.clone(), reads, listed: *listed, issues }); + if f.actions.scripts.calculate.as_deref().is_some_and(crate::af::reads_built_names) { + issues.push(CalculationIssue::BuiltNames); + } + out.push(CalculatedField { name: f.name.clone(), obj: f.obj, reads, listed: *listed, issues }); } let fixed = fixed_order(&links, &component); CalculationOrder { fields: out, extra, fixed } } +/// Whether a formula's read `name` finds a field in `names` (sorted): the field of that name or a +/// field under it (`row` for `row.1`), as [`links`], [`crate::recalculate`] and the scripts' +/// `getField` look names up. Acrobat's widget names (`price.0`) aren't fields to any of them, so +/// a read of one gets no value and is missing too. +fn found(names: &[&str], name: &str) -> bool { + let prefix = format!("{name}."); + let under = names.partition_point(|n| *n < prefix.as_str()); + names.binary_search(&name).is_ok() || names.get(under).is_some_and(|n| n.starts_with(&prefix)) +} + /// For each calculated field (by its place in `names`), the places of the calculated fields it /// reads, sorted. A read name finds the field of that name and the fields under it, as /// [`crate::recalculate`] looks values up. @@ -383,6 +408,19 @@ mod tests { assert_eq!(links(names.into_iter(), &reads)[5], [0, 1, 2], "row, row.1 and row.10; not rows or row-1"); } + #[test] + fn a_read_name_finds_a_field_or_a_group() { + let mut names = vec!["price", "row.1", "row.2", "rows", "total"]; + names.sort_unstable(); + for name in ["price", "row", "row.1", "rows"] { + assert!(found(&names, name), "{name}"); + } + // A widget name finds nothing: recalculation and getField don't resolve it either. + for name in ["pric", "ro", "row.3", "price.", "price.0", "price.12", "total.0.1", ""] { + assert!(!found(&names, name), "{name}"); + } + } + /// Two thousand formulas each reading a group of two thousand fields would be four million /// reads; the checks stop at their cap. #[test] diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index 39cebd8fdd..775306d865 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -27,7 +27,7 @@ pub mod table; pub use actions::{FieldAction, Trigger, field_action_chains, field_actions, set_field_actions}; pub use author::{ BorderStyle, CheckStyle, FieldFont, FieldProps, Look, LookPatch, MIN_FIELD_SIDE, NewField, add_field, check_style, delete_field, - delete_field_object, duplicate_field, look, redraw_field, set_button_icon, set_props, + delete_field_object, delete_widget, duplicate_field, look, redraw_field, set_button_icon, set_props, }; pub use calc_order::{CalculatedField, CalculationIssue, CalculationOrder, calculation_order, set_calculation_order}; pub use scripting::{ @@ -35,6 +35,27 @@ pub use scripting::{ set_field_script, }; +mod appearances; +mod read; +mod tab_order; +mod values; + +pub use appearances::{FieldAppearanceCache, declares_pdfa, need_appearances, undrawn_fields, with_field_appearances, with_field_appearances_cached}; +pub(crate) use read::page_refs; +pub use read::{Fields, acroform, adopted_page_fields, fields, fields_with_adopted}; +#[cfg(test)] +use read::{MAX_SCRIPT_BYTES, PageWalk, enumerate, form_reads}; +use read::{script, script_budget}; +pub use tab_order::{TabOrder, move_in_tab_order, set_tab_order, tab_orders}; +#[cfg(test)] +use values::has_password_flag; +use values::write_value; +pub(crate) use values::{ask_viewers_to_redraw, merged_appearance}; +pub use values::{ + calculations_changed, clear_password_values, has_password_values, password_values, recalculate, recalculate_with, reset, restore_password_values, + set_value, set_value_with, set_values, +}; + #[cfg(test)] mod tests; @@ -155,6 +176,12 @@ impl Field { self.flags & flag != 0 } + /// A comb field: the Comb flag set with a `/MaxLen`, neither multiline nor password (ISO + /// 32000-2 Table 228). Each cell holds one character. + pub fn is_comb(&self) -> bool { + self.has(flags::COMB) && !self.has(flags::MULTILINE) && !self.has(flags::PASSWORD) && self.max_len.is_some_and(|m| m > 0) + } + pub fn read_only(&self) -> bool { self.has(flags::READ_ONLY) } @@ -193,6 +220,15 @@ impl Field { self.widgets.iter().position(|w| w.on_state.as_deref() == Some(state)).and_then(|i| self.export_of(i)).unwrap_or(state) } + /// Values for data export, resolving button appearance names through /Opt. + pub fn export_values(&self) -> Vec { + if matches!(self.kind, FieldKind::CheckBox | FieldKind::Radio) { + self.value.iter().map(|v| self.export_for_state(v).to_string()).collect() + } else { + self.value.clone() + } + } + /// The value as one string: text, the state name, or the selected display texts. pub fn display_value(&self) -> String { match self.kind { @@ -267,1592 +303,6 @@ fn name_obj(text: &str) -> Object { Object::Name(name_bytes(text)) } -/// At most this many `/State` entries of a set-layer-visibility action are read. -const MAX_LAYER_STATE: usize = 1024; - -/// A push button's mouse-up action (`/A`, or `/AA /U`, on the field or its widget). -fn button_action(doc: &Document, d: &Dict, widgets: &[ObjRef], scripts: &mut DecodeBudget) -> Option { - // A button PdfCraft generated for an XFA image field (`/PCXfaImage true`, no PDF action): - // clicking it picks a picture, as `buttonImportIcon()` does. - if matches!(d.get(b"PCXfaImage").map(|o| doc.resolve(o)).as_deref(), Some(Object::Bool(true))) { - return Some(af::ButtonAction::ImportIcon); - } - let pick = |dict: &Dict| -> Option { - dict.get(b"A").cloned().or_else(|| dict.get(b"AA").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().and_then(|aa| aa.get(b"U").cloned()))) - }; - let action = pick(d).or_else(|| widgets.iter().find_map(|w| doc.get(*w).as_dict().and_then(pick)))?; - let a = doc.resolve(&action); - let a = a.as_dict()?; - let s = |k: &[u8]| a.get(k).and_then(|v| text_of(&doc.resolve(v))); - Some(match a.name(b"S")? { - b"ResetForm" => { - let fields = a - .get(b"Fields") - .map(|f| doc.resolve(f)) - .and_then(|f| f.as_array().cloned()) - .unwrap_or_default() - .iter() - .filter_map(|o| match o { - Object::Ref(r) => doc.get(*r).as_dict().and_then(|fd| fd.get(b"T").and_then(|t| text_of(&doc.resolve(t)))), - other => text_of(&doc.resolve(other)), - }) - .collect(); - let exclude = a.get(b"Flags").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0) & 1 != 0; - af::ButtonAction::Reset { fields, exclude } - } - b"Named" => af::ButtonAction::Named(String::from_utf8_lossy(a.name(b"N")?).into_owned()), - b"URI" => af::ButtonAction::Uri(s(b"URI")?), - b"SubmitForm" => af::ButtonAction::Submit( - a.get(b"F") - .map(|f| doc.resolve(f)) - .and_then(|f| f.as_dict().and_then(|fd| fd.get(b"F").and_then(|x| text_of(&doc.resolve(x)))).or_else(|| text_of(&f))) - .unwrap_or_default(), - ), - b"GoTo" => { - let dest = doc.resolve(a.get(b"D")?); - let target = dest.as_array()?.first()?.as_ref()?; - af::ButtonAction::GoTo(page_refs(doc).iter().position(|p| *p == target)?) - } - b"Hide" => { - // /T: a field name, an annotation (or field) reference, or an array of them. - let target = |o: &Object| match o { - Object::Ref(r) => qualified_name(doc, *r).or_else(|| text_of(&doc.resolve(o))), - other => text_of(other), - }; - let fields = match a.get(b"T") { - Some(t) => match &*doc.resolve(t) { - Object::Array(items) => items.iter().filter_map(target).collect(), - _ => target(t).into_iter().collect(), - }, - None => Vec::new(), - }; - let hide = !a.get(b"H").is_some_and(|h| matches!(&*doc.resolve(h), Object::Bool(false))); - af::ButtonAction::ShowHide { fields, hide } - } - b"SetOCGState" => { - // /State: ON, OFF or Toggle, each followed by the groups it applies to. - let mut changes = Vec::new(); - if let Some(state) = a.get(b"State") { - let mut op = None; - for item in doc.resolve(state).as_array().into_iter().flatten().take(MAX_LAYER_STATE) { - match item { - Object::Name(n) => { - op = match n.as_slice() { - b"ON" => Some(af::LayerOp::On), - b"OFF" => Some(af::LayerOp::Off), - b"Toggle" => Some(af::LayerOp::Toggle), - _ => None, - } - } - Object::Ref(r) => changes.extend(op.map(|op| (op, (r.num, r.generation)))), - _ => {} - } - } - } - let preserve_rb = !a.get(b"PreserveRB").is_some_and(|p| matches!(&*doc.resolve(p), Object::Bool(false))); - af::ButtonAction::SetLayers { changes, preserve_rb } - } - b"JavaScript" => af::button_script(&script(doc, &action, scripts)?), - _ => return None, - }) -} - -/// The fully qualified name of the field that `r` (a field or one of its widgets) belongs to, -/// from the partial names (`/T`) up its `/Parent` chain. -fn qualified_name(doc: &Document, r: ObjRef) -> Option { - let mut parts = Vec::new(); - let mut seen = std::collections::HashSet::new(); - let mut cur = Some(r); - while let Some(c) = cur { - if seen.len() > 64 || !seen.insert(c) { - break; - } - let obj = doc.get(c); - let d = obj.as_dict()?; - if let Some(t) = d.get(b"T").and_then(|o| text_of(&doc.resolve(o))) { - parts.push(t); - } - cur = d.get(b"Parent").and_then(|p| p.as_ref()); - } - parts.reverse(); - (!parts.is_empty()).then(|| parts.join(".")) -} - -/// JavaScript streams one reading of the form (its fields, or its document scripts) may decode -/// in all, however many actions name the same stream. -const MAX_SCRIPT_BYTES: usize = 64 << 20; - -/// A budget for the script streams of one reading of the form. -fn script_budget() -> DecodeBudget { - DecodeBudget::new(MAX_SCRIPT_BYTES, 0) -} - -/// The JavaScript of an action dictionary (`/JS` string or stream). A stream is decoded against -/// `budget`, and is `None` once it is spent. -fn script(doc: &Document, action: &Object, budget: &mut DecodeBudget) -> Option { - let a = doc.resolve(action); - let js = a.as_dict()?.get(b"JS")?.clone(); - match &*doc.resolve(&js) { - Object::String(s) => Some(s.to_text()), - Object::Stream(s) => budget.decode(s).ok().map(|b| String::from_utf8_lossy(&b).into_owned()), - _ => None, - } -} - -/// A field's format/keystroke, validate and calculate scripts (field dictionary, or its only -/// widget's when the field has none). -fn actions_of(doc: &Document, d: &Dict, widgets: &[ObjRef], scripts: &mut DecodeBudget) -> af::Actions { - let aa = d.get(b"AA").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()).or_else(|| { - (widgets.len() == 1) - .then(|| doc.get(widgets[0])) - .and_then(|w| w.as_dict().and_then(|wd| wd.get(b"AA").map(|a| doc.resolve(a)))) - .and_then(|a| a.as_dict().cloned()) - }); - let mut out = af::Actions::default(); - let Some(aa) = aa else { return out }; - let mut js = |k: &[u8]| aa.get(k).and_then(|a| script(doc, a, scripts)); - if let Some(f) = js(b"F") { - match af::parse_format(&f) { - Some(fm) => out.format = fm, - None if !f.trim().is_empty() => { - out.unsupported.push("format"); - out.scripts.format = Some(f); - } - None => {} - } - } - if let Some(k) = js(b"K") - && out.format == af::Format::None - { - // A mask has only a keystroke script. - match af::parse_format(&k) { - Some(fm @ af::Format::Mask(_)) => out.format = fm, - Some(_) => {} - None if !k.trim().is_empty() => { - out.unsupported.push("keystroke"); - out.scripts.keystroke = Some(k); - } - None => {} - } - } - if let Some(v) = js(b"V") { - match af::parse_validate(&v) { - Some(vv) => out.validate = vv, - None if !v.trim().is_empty() => { - out.unsupported.push("validate"); - out.scripts.validate = Some(v); - } - None => {} - } - } - if let Some(c) = js(b"C") { - match af::parse_calculate(&c) { - Some(cc) => out.calculate = cc, - None if !c.trim().is_empty() => { - out.unsupported.push("calculate"); - out.scripts.calculate = Some(c); - } - None => {} - } - } - out -} - -fn values_of(doc: &Document, o: Option<&Object>) -> Vec { - let Some(o) = o else { return Vec::new() }; - match &*doc.resolve(o) { - Object::Array(a) => a.iter().filter_map(|x| text_of(&doc.resolve(x))).collect(), - Object::Stream(s) => s.decoded().ok().map(|b| PdfString::literal(b).to_text()).into_iter().collect(), - other => text_of(other).filter(|s| s != "Off" && !s.is_empty()).into_iter().collect(), - } -} - -fn nums(doc: &Document, o: Option<&Object>) -> Option> { - let o = doc.resolve(o?); - o.as_array()?.iter().map(|x| doc.resolve(x).as_f64()).collect() -} - -/// Leaf page objects in order (a small walker; `organize` and `annot` have their own). -pub(crate) fn page_refs(doc: &Document) -> Vec { - let Some(root) = doc.root() else { return Vec::new() }; - let Some(pages) = doc.get(root).as_dict().and_then(|d| d.reference(b"Pages")) else { return Vec::new() }; - PageWalk::new(doc, pages).collect() -} - -/// Depth-first walk over the leaf pages under `top`. It keeps one cursor per open `/Kids` array, -/// never one entry per child, so a child listed many times or shared between nodes is skipped by -/// the seen check without being queued first (`pdfcraft_model::pages` walks the same way). -/// Cycles end at the seen check and at most a million nodes are visited. -struct PageWalk<'a> { - doc: &'a Document, - seen: std::collections::HashSet, - pending: Vec<(std::sync::Arc, usize)>, -} - -impl<'a> PageWalk<'a> { - fn new(doc: &'a Document, top: ObjRef) -> Self { - let top = std::sync::Arc::new(Object::Array(vec![Object::Ref(top)])); - Self { doc, seen: Default::default(), pending: vec![(top, 0)] } - } -} - -impl Iterator for PageWalk<'_> { - type Item = ObjRef; - - fn next(&mut self) -> Option { - while let Some((kids, next)) = self.pending.last_mut() { - let Some(kid) = kids.as_array().and_then(|a| a.get(*next)) else { - self.pending.pop(); - continue; - }; - let node = kid.as_ref(); - *next += 1; - let Some(node) = node else { continue }; - if self.seen.len() >= 1_000_000 { - self.pending.clear(); - return None; - } - if !self.seen.insert(node) { - continue; - } - let obj = self.doc.get(node); - let Some(d) = obj.as_dict() else { continue }; - match d.get(b"Kids").map(|k| self.doc.resolve(k)) { - Some(kids) if d.name(b"Type") != Some(b"Page") => self.pending.push((kids, 0)), - _ => return Some(node), - } - } - None - } -} - -/// The AcroForm dictionary. -pub fn acroform(doc: &Document) -> Option { - let root = doc.root()?; - let cat = doc.get(root); - let af = cat.as_dict()?.get(b"AcroForm")?.clone(); - doc.resolve(&af).as_dict().cloned() -} - -#[derive(Clone, Default)] -struct Inherited { - name: String, - ft: Option>, - ff: Option, - v: Option, - dv: Option, - da: Option, - q: Option, - max_len: Option, -} - -/// Every terminal field: the `/Fields` tree in order, then the fields reachable only through the -/// page annotations (see [`adopt_page_fields`]). -pub fn fields(doc: &Document) -> Vec { - fields_with_adopted(doc).items -} - -/// How many of [`fields`] were reachable only through the page annotations: the form's `/Fields` -/// list names none or only some of them, so the leniency that adopts them is worth recording. -pub fn adopted_page_fields(doc: &Document) -> usize { - fields_with_adopted(doc).adopted_page_fields -} - -/// The terminal fields and page-only fields adopted while reading a form. -#[derive(Clone, Debug, PartialEq)] -pub struct Fields { - /// Terminal fields in tree order, followed by adopted page-only fields. - pub items: Vec, - /// How many fields were reachable only through page annotations. - pub adopted_page_fields: usize, -} - -/// Read all terminal fields and the page-only adoption count in one enumeration. -pub fn fields_with_adopted(doc: &Document) -> Fields { - let (items, adopted_page_fields) = enumerate(doc); - Fields { items, adopted_page_fields } -} - -#[cfg(test)] -thread_local! { - /// Whole-form readings ([`enumerate`]) on this thread. - static FORM_READS: std::cell::Cell = const { std::cell::Cell::new(0) }; -} - -/// How many times this thread has read the whole form. -#[cfg(test)] -fn form_reads() -> usize { - FORM_READS.with(std::cell::Cell::get) -} - -fn enumerate(doc: &Document) -> (Vec, usize) { - #[cfg(test)] - FORM_READS.with(|n| n.set(n.get() + 1)); - let af = acroform(doc); - let mut page_of = std::collections::HashMap::new(); - let mut annot_index = std::collections::HashMap::new(); - let pages = page_refs(doc); - for (i, p) in pages.iter().enumerate() { - if let Some(a) = doc.get(*p).as_dict().and_then(|d| d.get(b"Annots").cloned()) { - for (k, e) in doc.resolve(&a).as_array().into_iter().flatten().enumerate() { - if let Some(r) = e.as_ref() { - page_of.entry(r).or_insert(i); - annot_index.entry(r).or_insert(k); - } - } - } - } - let base = match &af { - Some(af) => Inherited { - da: af.get(b"DA").and_then(|o| text_of(&doc.resolve(o))), - q: af.get(b"Q").and_then(|o| doc.resolve(o).as_int()), - ..Default::default() - }, - None => Inherited::default(), - }; - let mut out = Vec::new(); - let mut seen = Seen { fields: std::collections::HashSet::new(), widgets: std::collections::HashSet::new(), scripts: script_budget() }; - if let Some(af) = &af { - for f in af.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default() { - if let Some(r) = f.as_ref() { - walk(doc, r, &base, &page_of, &mut seen, &mut out, 0); - } - } - } - let listed = out.len(); - adopt_page_fields(doc, &pages, &base, &page_of, &mut seen, &mut out); - let adopted = out.len() - listed; - rank_tabs(doc, &pages, &annot_index, &mut out); - (out, adopted) -} - -/// Widget annotations no `/Fields` entry reaches are adopted as fields, as Acrobat and the -/// browsers do: some writers list only some of the fields, or none at all. Each widget is adopted -/// through its topmost unlisted `/Parent`, so a field split across several widgets stays one -/// field. Tree-listed fields keep their order first; the rest follow in page-annotation order. -fn adopt_page_fields( - doc: &Document, - pages: &[ObjRef], - base: &Inherited, - page_of: &std::collections::HashMap, - seen: &mut Seen, - out: &mut Vec, -) { - for p in pages { - let Some(a) = doc.get(*p).as_dict().and_then(|d| d.get(b"Annots").cloned()) else { continue }; - let annots = doc.resolve(&a); - let Some(annots) = annots.as_array() else { continue }; - for e in annots { - let Some(r) = e.as_ref() else { continue }; - if seen.fields.contains(&r) || seen.widgets.contains(&r) { - continue; - } - let obj = doc.get(r); - let Some(d) = obj.as_dict() else { continue }; - if d.name(b"Subtype") != Some(b"Widget") { - continue; - } - if !d.contains(b"FT") && !d.contains(b"T") && !d.contains(b"Parent") { - continue; - } - if let Some(root) = unlisted_root(doc, r, &seen.fields) { - walk(doc, root, base, page_of, seen, out, 0); - } - } - } -} - -/// The topmost field above `r` (itself without a `/Parent`), or `None` when `r` or an ancestor is -/// already reachable from the `/Fields` tree, or a `/Parent` loop hides the top. -fn unlisted_root(doc: &Document, r: ObjRef, seen: &std::collections::HashSet) -> Option { - let mut top = r; - let mut visited = std::collections::HashSet::from([r]); - loop { - let parent = doc.get(top).as_dict().and_then(|d| d.reference(b"Parent")); - let Some(p) = parent else { - return if seen.contains(&top) { None } else { Some(top) }; - }; - if !visited.insert(p) || visited.len() > 64 { - return None; - } - if seen.contains(&p) { - return None; - } - top = p; - } -} - -/// A page's tab order (`/Tabs`). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum TabOrder { - /// Rows, top to bottom, left to right within a row (`/R`). - Row, - /// Columns, left to right, top to bottom within a column (`/C`). - Column, - /// The document structure (`/S`; annotation order when the file has none). - Structure, - /// Unspecified: annotation order. - Annotations, -} - -impl TabOrder { - fn of(name: Option<&[u8]>) -> TabOrder { - match name { - Some(b"R") => TabOrder::Row, - Some(b"C") => TabOrder::Column, - Some(b"S") => TabOrder::Structure, - _ => TabOrder::Annotations, - } - } -} - -/// A widget on a page for tab ordering: (page, field, widget, rect, annotation index). -type TabItem = (usize, usize, usize, [f64; 4], usize); - -/// Every widget's place in the tab order. -fn rank_tabs(doc: &Document, pages: &[ObjRef], annot_index: &std::collections::HashMap, out: &mut [Field]) { - let mut items: Vec = Vec::new(); - for (fi, f) in out.iter().enumerate() { - for (wi, w) in f.widgets.iter().enumerate() { - if let Some(p) = w.page { - items.push((p, fi, wi, w.rect, annot_index.get(&w.obj).copied().unwrap_or(usize::MAX))); - } - } - } - let mut ordered: Vec<(usize, usize)> = Vec::with_capacity(items.len()); - for (pi, p) in pages.iter().enumerate() { - let mut on: Vec<_> = items.iter().filter(|i| i.0 == pi).collect(); - if on.is_empty() { - continue; - } - let tabs = TabOrder::of(doc.get(*p).as_dict().and_then(|d| d.name(b"Tabs"))); - match tabs { - TabOrder::Row | TabOrder::Column => { - let row = tabs == TabOrder::Row; - // Primary axis: rows by top edge (down the page), columns by left edge. - on.sort_by(|a, b| if row { b.3[3].total_cmp(&a.3[3]) } else { a.3[0].total_cmp(&b.3[0]) }); - let mut groups: Vec> = Vec::new(); - for it in on { - let fits = groups.last().is_some_and(|g| { - let first = g[0]; - if row { - (first.3[3] - it.3[3]).abs() < ((first.3[3] - first.3[1]) / 2.0).max(2.0) - } else { - (it.3[0] - first.3[0]).abs() < ((first.3[2] - first.3[0]) / 2.0).max(2.0) - } - }); - if fits && let Some(g) = groups.last_mut() { - g.push(it); - } else { - groups.push(vec![it]); - } - } - for mut g in groups { - g.sort_by(|a, b| if row { a.3[0].total_cmp(&b.3[0]) } else { b.3[3].total_cmp(&a.3[3]) }); - ordered.extend(g.iter().map(|i| (i.1, i.2))); - } - } - TabOrder::Structure | TabOrder::Annotations => { - on.sort_by_key(|i| i.4); - ordered.extend(on.iter().map(|i| (i.1, i.2))); - } - } - } - for f in out.iter_mut() { - for w in &mut f.widgets { - w.tab = usize::MAX; - } - } - for (rank, (fi, wi)) in ordered.into_iter().enumerate() { - out[fi].widgets[wi].tab = rank; - } -} - -/// Set the tab order of pages (0-based). -pub fn set_tab_order(doc: &mut Document, pages: &[usize], order: TabOrder) -> Result<(), FormError> { - let refs = page_refs(doc); - for &p in pages { - let r = *refs.get(p).ok_or_else(|| FormError::Invalid(format!("page {} does not exist", p + 1)))?; - doc.update_dict(r, |d| match order { - TabOrder::Row => d.set(b"Tabs".to_vec(), Object::name("R")), - TabOrder::Column => d.set(b"Tabs".to_vec(), Object::name("C")), - TabOrder::Structure => d.set(b"Tabs".to_vec(), Object::name("S")), - TabOrder::Annotations => { - d.remove(b"Tabs"); - } - })?; - } - Ok(()) -} - -/// What one reading of the form's fields has used: the field and widget dictionaries read, and -/// the script budget their actions share. -struct Seen { - fields: std::collections::HashSet, - /// A widget belongs to the first field that lists it, however often it is listed. - widgets: std::collections::HashSet, - scripts: DecodeBudget, -} - -/// Read the field at `r` and the fields under it into `out`, with what they inherit from -/// `parent`: kids with a name are fields of their own, kids without are this field's widgets. -fn walk( - doc: &Document, - r: ObjRef, - parent: &Inherited, - page_of: &std::collections::HashMap, - seen: &mut Seen, - out: &mut Vec, - depth: usize, -) { - if depth > 64 || !seen.fields.insert(r) { - return; - } - let obj = doc.get(r); - let Some(d) = obj.as_dict() else { return }; - let mut inh = parent.clone(); - if let Some(t) = d.get(b"T").and_then(|o| text_of(&doc.resolve(o))) { - inh.name = if inh.name.is_empty() { t } else { format!("{}.{t}", inh.name) }; - } - if let Some(ft) = d.name(b"FT") { - inh.ft = Some(ft.to_vec()); - } - if let Some(ff) = field_flags(doc, d) { - inh.ff = Some(ff); - } - for (k, slot) in [(&b"V"[..], &mut inh.v), (b"DV", &mut inh.dv)] { - if let Some(v) = d.get(k) { - *slot = Some(v.clone()); - } - } - if let Some(da) = d.get(b"DA").and_then(|o| text_of(&doc.resolve(o))) { - inh.da = Some(da); - } - if let Some(q) = d.get(b"Q").and_then(|o| doc.resolve(o).as_int()) { - inh.q = Some(q); - } - if let Some(m) = d.get(b"MaxLen").and_then(|o| doc.resolve(o).as_int()) { - // Compatibility: some writers emit zero for an unlimited text field. Keep /MaxLen - // untouched in the object graph, but expose only positive limits to both UI and API. - // An explicit zero also clears an inherited limit rather than inheriting it again. - inh.max_len = usize::try_from(m).ok().filter(|limit| *limit > 0); - } - let kids: Vec = - d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default().iter().filter_map(|k| k.as_ref()).collect(); - // Kids with /T are fields; kids without are this field's widgets. - let field_kids: Vec = kids.iter().copied().filter(|k| doc.get(*k).as_dict().is_some_and(|kd| kd.contains(b"T"))).collect(); - if !field_kids.is_empty() { - for k in field_kids { - walk(doc, k, &inh, page_of, seen, out, depth + 1); - } - return; - } - // A field of no kind this reads is skipped before it claims widgets or scripts, which a later - // field listing them then reads. - let ff = inh.ff.unwrap_or(0); - let kind = match inh.ft.as_deref() { - Some(b"Tx") => FieldKind::Text, - Some(b"Btn") if ff & flags::PUSH_BUTTON != 0 => FieldKind::PushButton, - Some(b"Btn") if ff & flags::RADIO != 0 => FieldKind::Radio, - Some(b"Btn") => FieldKind::CheckBox, - Some(b"Ch") if ff & flags::COMBO != 0 => FieldKind::Combo, - Some(b"Ch") => FieldKind::List, - Some(b"Sig") => FieldKind::Signature, - _ => return, - }; - let widget_refs: Vec = if d.contains(b"Rect") { vec![r] } else { kids }; - let widget_refs: Vec = widget_refs.into_iter().filter(|w| seen.widgets.insert(*w)).collect(); - let actions = actions_of(doc, d, &widget_refs, &mut seen.scripts); - let button = (inh.ft.as_deref() == Some(b"Btn") && inh.ff.unwrap_or(0) & flags::PUSH_BUTTON != 0) - .then(|| button_action(doc, d, &widget_refs, &mut seen.scripts)) - .flatten(); - let widgets = widget_refs - .into_iter() - .filter_map(|w| { - let wo = doc.get(w); - let wd = wo.as_dict()?; - let rect = nums(doc, wd.get(b"Rect")).filter(|r| r.len() == 4).unwrap_or_else(|| vec![0.0; 4]); - let annot_flags = wd.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0); - let own_ff = if w == r { 0 } else { field_flags(doc, wd).unwrap_or(0) }; - let on_state = wd - .get(b"AP") - .map(|ap| doc.resolve(ap)) - .and_then(|ap| ap.as_dict().and_then(|a| a.get(b"N").cloned())) - .and_then(|n| doc.resolve(&n).as_dict().cloned()) - .and_then(|n| n.iter().map(|(k, _)| name_text(k)).find(|k| k != "Off")); - Some(Widget { - obj: w, - page: page_of.get(&w).copied(), - rect: [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])], - on_state: on_state.filter(|_| matches!(kind, FieldKind::CheckBox | FieldKind::Radio)), - state: wd.name(b"AS").map(name_text), - tab: usize::MAX, - locked: annot_flags & 128 != 0, - hidden: annot_flags & (2 | 32) != 0, - read_only: (ff | own_ff) & flags::READ_ONLY != 0, - rotation: appearance::mk_rotation(doc, wd), - caption: (kind == FieldKind::PushButton) - .then(|| { - let mk = doc.resolve(wd.get(b"MK")?); - let value = doc.resolve(mk.as_dict()?.get(b"CA")?); - value.as_string().map(|s| s.to_text()) - }) - .flatten(), - list: (kind == FieldKind::List).then(|| appearance::list_rows_of(doc, inh.da.as_deref().unwrap_or("/Helv 0 Tf 0 g"), Some(d), wd)), - round: kind == FieldKind::Radio || (kind == FieldKind::CheckBox && appearance::is_round(doc, wd)), - }) - }) - .collect(); - let options = d - .get(b"Opt") - .map(|o| doc.resolve(o)) - .and_then(|o| o.as_array().cloned()) - .unwrap_or_default() - .iter() - .filter_map(|o| match &*doc.resolve(o) { - Object::Array(pair) if pair.len() >= 2 => Some((text_of(&doc.resolve(&pair[0]))?, text_of(&doc.resolve(&pair[1]))?)), - other => text_of(other).map(|t| (t.clone(), t)), - }) - .collect(); - out.push(Field { - name: inh.name.clone(), - obj: r, - kind, - value: values_of(doc, inh.v.as_ref()), - default: values_of(doc, inh.dv.as_ref()), - flags: ff, - max_len: inh.max_len, - options, - da: inh.da.clone().unwrap_or_else(|| "/Helv 0 Tf 0 g".into()), - quadding: inh.q.unwrap_or(0), - tooltip: d.get(b"TU").and_then(|o| text_of(&doc.resolve(o))), - widgets, - actions, - button, - }); -} - -fn field_flags(doc: &Document, dict: &Dict) -> Option { - dict.get(b"Ff").and_then(|value| doc.resolve(value).as_int()).and_then(|value| u32::try_from(value).ok()) -} - -// ── writing ───────────────────────────────────────────────────────────────────────────────── - -/// Fill the field `name`. -pub fn set_value(doc: &mut Document, name: &str, value: &FieldValue) -> Result<(), FormError> { - set_value_with(doc, name, value, &mut NoScripts) -} - -/// [`set_value`], running the fields' JavaScript through `scripts`. -pub fn set_value_with(doc: &mut Document, name: &str, value: &FieldValue, scripts: &mut dyn Scripts) -> Result<(), FormError> { - let all = fields(doc); - if all.is_empty() { - return Err(FormError::NoForm); - } - let named: Vec = all.iter().enumerate().filter(|(_, f)| f.name == name).map(|(i, _)| i).collect(); - fill(doc, &all, &named, name, value, scripts)?; - recalculate_with(doc, scripts)?; - Ok(()) -} - -/// Fill several fields, as [`set_value`] fills each in turn, but reading the form once and -/// recalculating once, after the last value. Filling fields one by one reads the whole form twice -/// per field, which takes minutes on a form with thousands of fields. -/// -/// Returns each value's result, in order. The outer `Err` is the recalculation failing, after -/// which, as with [`set_value`], no value counts as filled (though each was written). -pub fn set_values(doc: &mut Document, values: &[(String, FieldValue)]) -> Result>, FormError> { - let mut scripts = NoScripts; - let mut form = NamedFields::read(doc); - // The field objects this batch wrote since the form was last read. - let mut written = std::collections::HashSet::new(); - let mut results = Vec::with_capacity(values.len()); - let mut filled = false; - for (name, value) in values { - if form.all.is_empty() { - results.push(Err(FormError::NoForm)); - continue; - } - // A field written earlier in the batch (named twice, or a twin's name) is read again, - // so this write starts from what the earlier one left, as it would one by one. - if form.objs(name).any(|o| written.contains(&o)) { - form = NamedFields::read(doc); - written.clear(); - } - let named = form.named(name); - let result = fill(doc, &form.all, named, name, value, &mut scripts); - written.extend(form.objs(name)); - filled |= result.is_ok(); - results.push(result); - } - if filled { - recalculate_with(doc, &mut scripts)?; - } - Ok(results) -} - -/// The form's fields, with the positions of each fully qualified name in them (in field order). -struct NamedFields { - all: Vec, - by_name: std::collections::HashMap>, -} - -impl NamedFields { - fn read(doc: &Document) -> Self { - let all = fields(doc); - let mut by_name: std::collections::HashMap> = std::collections::HashMap::new(); - for (i, f) in all.iter().enumerate() { - by_name.entry(f.name.clone()).or_default().push(i); - } - NamedFields { all, by_name } - } - - fn named(&self, name: &str) -> &[usize] { - self.by_name.get(name).map_or(&[], Vec::as_slice) - } - - /// The field objects named `name`. - fn objs<'a>(&'a self, name: &str) -> impl Iterator + 'a { - self.named(name).iter().filter_map(|&i| self.all.get(i)).map(|f| f.obj) - } -} - -/// Write `value` into the field `name`, without recalculating: `named` are the positions in -/// `all` (the form as read before this write) of the fields with that name, in field order. -fn fill(doc: &mut Document, all: &[Field], named: &[usize], name: &str, value: &FieldValue, scripts: &mut dyn Scripts) -> Result<(), FormError> { - let f = named.first().and_then(|&i| all.get(i)).ok_or_else(|| FormError::NoSuchField(name.into()))?; - if f.read_only() { - return Err(FormError::ReadOnly(name.into())); - } - write_value(doc, f, value, scripts)?; - author::form_fonts_as_objects(doc, all)?; - // Fields with the same fully qualified name are one field (§12.7.3.1). Writers that repeat a - // name as separate field dictionaries, instead of widgets under one parent, get the value - // the first one took after its scripts, without running them again. - let twins: Vec<&Field> = named.iter().filter_map(|&i| all.get(i)).filter(|t| t.obj != f.obj && t.kind == f.kind).collect(); - if !twins.is_empty() { - let taken = fields(doc).into_iter().find(|x| x.obj == f.obj).map(|x| x.value).unwrap_or_default(); - for t in twins { - share_value(doc, f.obj, t, &taken, scripts)?; - } - } - Ok(()) -} - -/// Give `twin` the value `source` holds: its `/V` (and a choice's `/I`), drawn in the twin's own -/// widgets. -fn share_value(doc: &mut Document, source: ObjRef, twin: &Field, value: &[String], scripts: &mut dyn Scripts) -> Result<(), FormError> { - match twin.kind { - FieldKind::CheckBox => { - let state = twin.widgets.iter().find_map(|w| w.on_state.clone()).unwrap_or_else(|| "Yes".into()); - set_states(doc, twin, (!value.is_empty()).then_some(state.as_str())) - } - FieldKind::Radio => { - let choice = value.first().filter(|c| twin.widgets.iter().any(|w| w.on_state.as_deref() == Some(c.as_str()))); - set_states(doc, twin, choice.map(String::as_str)) - } - FieldKind::Text | FieldKind::Combo | FieldKind::List => { - let src = doc.get(source).as_dict().cloned().unwrap_or_default(); - doc.update_dict(twin.obj, |d| { - for key in [&b"V"[..], b"I"] { - match src.get(key) { - Some(v) => d.set(key.to_vec(), v.clone()), - None => { - d.remove(key); - } - } - } - d.remove(b"RV"); - })?; - redraw(doc, twin, value, scripts) - } - FieldKind::PushButton | FieldKind::Signature => Ok(()), - } -} - -/// Run every field's Calculate script in the form's calculation order (`/CO`, then the other -/// calculated fields), as Acrobat does after any value changes. Returns how many changed. -pub fn recalculate(doc: &mut Document) -> Result { - recalculate_with(doc, &mut NoScripts) -} - -/// [`recalculate`], running custom Calculate (and Format) scripts through `scripts`. -pub fn recalculate_with(doc: &mut Document, scripts: &mut dyn Scripts) -> Result { - let mut all = fields(doc); - if !all.iter().any(calc_order::is_calculated) { - return Ok(0); - } - // By object: two fields can share a name in a malformed form, and each calculates. - let order: Vec = calc_order::run_order(doc, &all).0.into_iter().filter_map(|(i, _)| all.get(i)).map(|f| f.obj).collect(); - let mut changed = 0; - for obj in order { - let Some(f) = all.iter().find(|f| f.obj == obj).cloned() else { continue }; - let lookup = |n: &str| -> Vec { - let prefix = format!("{n}."); - all.iter().filter(|x| x.name == n || x.name.starts_with(&prefix)).flat_map(|x| x.value.first().cloned()).collect() - }; - let current = f.value.first().cloned().unwrap_or_default(); - let v = if let Some(js) = &f.actions.scripts.calculate { - let r = scripts.run(FieldEvent::Calculate, js, &f, ¤t, &all); - scripting::apply_changes(doc, &r.changes, &f.name)?; - if !r.changes.is_empty() { - all = fields(doc); - } - if !r.rc { - continue; - } - r.value - } else { - let Some(v) = af::calculate(&f.actions.calculate, &lookup) else { continue }; - v - }; - if current != v && matches!(f.kind, FieldKind::Text | FieldKind::Combo) { - doc.update_dict(f.obj, |d| d.set(b"V".to_vec(), PdfString::text(&v)))?; - redraw(doc, &f, std::slice::from_ref(&v), scripts)?; - if let Some(target) = all.iter_mut().find(|x| x.obj == obj) { - target.value = vec![v]; - } - changed += 1; - } - } - Ok(changed) -} - -/// The fields whose calculation differs between two readings of a form, matched by field object -/// and named as in `after`: the formulas an edit rewrote, such as a rename's. -pub fn calculations_changed(before: &[Field], after: &[Field]) -> Vec { - let before: std::collections::HashMap = before.iter().map(|f| (f.obj, &f.actions)).collect(); - after - .iter() - .filter(|f| before.get(&f.obj).is_some_and(|b| b.calculate != f.actions.calculate || b.scripts.calculate != f.actions.scripts.calculate)) - .map(|f| f.name.clone()) - .collect() -} - fn invalid(m: impl Into) -> Result { Err(FormError::Invalid(m.into())) } - -fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scripts: &mut dyn Scripts) -> Result<(), FormError> { - match (f.kind, value) { - (FieldKind::Text, FieldValue::Text(t)) => { - if let Some(max) = f.max_len - && t.chars().count() > max - { - return invalid(format!("{:?} takes at most {max} characters", f.name)); - } - // Keystroke (on commit) and Validate, as Acrobat runs them before accepting a value. - let t = &af::keystroke(&f.actions.format, &f.name, t).map_err(FormError::Format)?; - af::validate(&f.actions.validate, t).map_err(FormError::Invalid)?; - let t = &scripting::accept(doc, f, t, scripts)?; - doc.update_dict(f.obj, |d| { - if t.is_empty() { - d.remove(b"V"); - } else { - d.set(b"V".to_vec(), PdfString::text(t)); - } - d.remove(b"RV"); - })?; - redraw(doc, f, std::slice::from_ref(t), scripts) - } - (FieldKind::CheckBox, v) => { - let state = f.widgets.iter().find_map(|w| w.on_state.clone()).unwrap_or_else(|| "Yes".into()); - let on = match v { - FieldValue::Check(on) => *on, - // The on-state name or a yes/no word also work (agents, FDF-style data). - FieldValue::Text(t) | FieldValue::Radio(Some(t)) => { - let t = t.trim(); - if t == state || f.state_for(t).is_some() || ["yes", "true", "on", "1", "x", "checked"].contains(&t.to_lowercase().as_str()) { - true - } else if t.is_empty() || ["no", "false", "off", "0", "unchecked"].contains(&t.to_lowercase().as_str()) { - false - } else { - return invalid(format!("{:?} is a check box: use true or false", f.name)); - } - } - FieldValue::Radio(None) => false, - FieldValue::Choice(_) => return invalid(format!("{:?} is a check box: use true or false", f.name)), - }; - set_states(doc, f, on.then_some(state.as_str())) - } - (FieldKind::Radio, v) => { - let choice = match v { - FieldValue::Radio(c) => c.clone(), - FieldValue::Text(t) if t.is_empty() => None, - FieldValue::Text(t) => Some(t.clone()), - FieldValue::Check(false) => None, - _ => return invalid(format!("{:?} is a radio group: choose one of its options", f.name)), - }; - // An export value from /Opt selects its widget's on state. - let choice = choice.map(|c| f.state_for(&c).map(str::to_owned).unwrap_or(c)); - if let Some(c) = &choice - && !f.widgets.iter().any(|w| w.on_state.as_deref() == Some(c.as_str())) - { - let opts: Vec<&str> = (0..f.widgets.len()).filter_map(|i| f.export_of(i)).collect(); - return invalid(format!("{:?} has no option {c:?} (options: {})", f.name, opts.join(", "))); - } - if choice.is_none() && f.has(flags::NO_TOGGLE_TO_OFF) && !f.value.is_empty() { - return invalid(format!("{:?} must keep one option selected", f.name)); - } - set_states(doc, f, choice.as_deref()) - } - (FieldKind::Combo | FieldKind::List, FieldValue::Choice(_) | FieldValue::Text(_)) => { - let vals: Vec = match value { - FieldValue::Choice(v) => v.clone(), - FieldValue::Text(t) if t.is_empty() => Vec::new(), - FieldValue::Text(t) => vec![t.clone()], - other => return invalid(format!("{:?} can't take {other:?}", f.name)), - }; - if vals.len() > 1 && !(f.kind == FieldKind::List && f.has(flags::MULTI_SELECT)) { - return invalid(format!("{:?} takes a single value", f.name)); - } - // Accept export values or display texts; free text only in editable combo boxes. - let mut exports = Vec::new(); - for v in &vals { - match f.options.iter().find(|(e, d)| e == v || d == v) { - Some((e, _)) => exports.push(e.clone()), - None if f.kind == FieldKind::Combo && f.has(flags::EDIT) => exports.push(v.clone()), - None if f.options.is_empty() => exports.push(v.clone()), - None => { - let opts: Vec<&str> = f.options.iter().map(|(_, d)| d.as_str()).collect(); - return invalid(format!("{:?} has no option {v:?} (options: {})", f.name, opts.join(", "))); - } - } - } - let mut indices: Vec = - exports.iter().filter_map(|e| f.options.iter().position(|(x, _)| x == e)).map(|i| Object::Int(i as i64)).collect(); - // /I lists option indices in ascending order; /V keeps the caller's value order. - // Every entry above is an integer, so this sorts by its numeric option index. - indices.sort_unstable_by_key(Object::as_int); - // A list box scrolls (`/TI`) so the picked options are among the rows it draws, as - // in other viewers: a pick below the last visible row would otherwise not show (#1524). - let top = match (f.kind, f.widgets.first()) { - (FieldKind::List, Some(w)) => w.list.map(|rows| { - let height = if matches!(w.rotation, 90 | 270) { w.rect[2] - w.rect[0] } else { w.rect[3] - w.rect[1] }; - let picked: Vec = indices.iter().filter_map(|i| i.as_int()).filter_map(|i| usize::try_from(i).ok()).collect(); - (rows.top, appearance::ListRows::top_showing(rows.top, &picked, rows.visible(height), f.options.len())) - }), - _ => None, - }; - doc.update_dict(f.obj, |d| { - match exports.as_slice() { - [] => { - d.remove(b"V"); - } - [one] => d.set(b"V".to_vec(), PdfString::text(one)), - many => d.set(b"V".to_vec(), Object::Array(many.iter().map(|e| Object::String(PdfString::text(e))).collect())), - } - if indices.is_empty() { - d.remove(b"I"); - } else { - d.set(b"I".to_vec(), Object::Array(indices)); - } - if let Some((old, new)) = top - && old != new - { - d.set(b"TI".to_vec(), Object::Int(i64::try_from(new).unwrap_or(0))); - } - })?; - // A widget with its own `/TI` (read before the field's) scrolls too. - if let Some((old, new)) = top - && old != new - { - for w in f.widgets.iter().filter(|w| w.obj != f.obj) { - if doc.get(w.obj).as_dict().is_some_and(|d| d.contains(b"TI")) { - doc.update_dict(w.obj, |d| d.set(b"TI".to_vec(), Object::Int(i64::try_from(new).unwrap_or(0))))?; - } - } - } - redraw(doc, f, &exports, scripts) - } - (FieldKind::PushButton, _) => invalid(format!("{:?} is a button; it has no value", f.name)), - (FieldKind::Signature, _) => invalid(format!("{:?} is a signature field; sign it with Fill & Sign or a digital ID", f.name)), - (kind, v) => invalid(format!("{:?} is a {kind:?} field and can't take {v:?}", f.name)), - } -} - -/// Check boxes and radio buttons: `/V` on the field, `/AS` on each widget. -fn set_states(doc: &mut Document, f: &Field, on: Option<&str>) -> Result<(), FormError> { - let v = on.unwrap_or("Off"); - doc.update_dict(f.obj, |d| d.set(b"V".to_vec(), name_obj(v)))?; - for w in &f.widgets { - let state = match (on, w.on_state.as_deref()) { - (Some(c), Some(s)) if c == s => s, - _ => "Off", - }; - // A widget without appearances for its states gets PdfCraft's own. - let has_ap = doc.get(w.obj).as_dict().and_then(|d| d.get(b"AP").cloned()).is_some(); - if !has_ap { - let on_name = w.on_state.clone().unwrap_or_else(|| on.unwrap_or("Yes").to_string()); - let ap = appearance::check_box_states(doc, w, f.kind, &on_name, &f.da); - doc.update_dict(w.obj, |d| d.set(b"AP".to_vec(), Object::Dict(ap)))?; - } - doc.update_dict(w.obj, |d| d.set(b"AS".to_vec(), name_obj(state)))?; - } - Ok(()) -} - -/// Copy an existing appearance dictionary before replacing its normal appearance. -fn appearance_dict(doc: &Document, widget: ObjRef) -> Dict { - doc.get(widget).as_dict().and_then(|d| d.get(b"AP").map(|a| doc.resolve(a))).and_then(|a| a.as_dict().cloned()).unwrap_or_default() -} - -/// `widget`'s appearance dictionary (a copy, so a shared one is left alone) with the freshly -/// drawn entries of `fresh` (its `/N`). Other entries are kept, but the old down and rollover -/// appearances (`/D`, `/R`) would show the old value, caption or colour on press or hover, so -/// they go — except, for check boxes and radio buttons (whose `/N` is a dictionary of states), -/// the states the new `/N` still draws under the same names. -pub(crate) fn merged_appearance(doc: &Document, widget: ObjRef, fresh: &Dict) -> Dict { - let mut apd = appearance_dict(doc, widget); - // Only a dictionary of states counts (`as_dict` would also see a stream's own dictionary). - let states_of = |o: &Object| match &*doc.resolve(o) { - Object::Dict(d) => Some(d.clone()), - _ => None, - }; - let states: Option>> = fresh.get(b"N").and_then(states_of).map(|d| d.iter().map(|(k, _)| k.clone()).collect()); - for key in [&b"D"[..], &b"R"[..]] { - if fresh.contains(key) { - continue; - } - let kept = states.as_ref().and_then(|names| { - let old = apd.get(key).and_then(states_of)?; - let mut d = Dict::new(); - for (k, v) in old.iter().filter(|(k, _)| names.contains(k)) { - d.set(k.clone(), v.clone()); - } - (!d.is_empty()).then_some(d) - }); - match kept { - Some(d) => apd.set(key.to_vec(), Object::Dict(d)), - None => { - apd.remove(key); - } - } - } - for (k, v) in fresh.iter() { - apd.set(k.clone(), v.clone()); - } - apd -} - -/// Regenerate the normal appearance of every widget of a text or choice field. -fn redraw(doc: &mut Document, f: &Field, values: &[String], scripts: &mut dyn Scripts) -> Result<(), FormError> { - let shown = match values { - [one] if matches!(f.kind, FieldKind::Text | FieldKind::Combo) => scripting::formatted(doc, f, one, scripts), - _ => None, - }; - let in_place = f.kind == FieldKind::Text && f.has(flags::PASSWORD); - let mut undrawn = false; - for w in &f.widgets { - let (stream, missing) = match &shown { - Some(s) => appearance::field_appearance_drawn(doc, f, w, std::slice::from_ref(s), false), - None => appearance::field_appearance_drawn(doc, f, w, values, true), - }; - undrawn |= missing.is_some(); - let existing_ap = in_place - .then(|| { - doc.get(w.obj) - .as_dict() - .and_then(|d| d.get(b"AP").map(|a| doc.resolve(a))) - .and_then(|a| a.as_dict().and_then(|a| a.get(b"N").cloned())) - .and_then(|n| n.as_ref()) - .filter(|r| doc.is_edited(r.num) && matches!(&*doc.get(*r), Object::Stream(_))) - }) - .flatten(); - let ap = match existing_ap { - Some(r) => { - doc.set(r, Object::Stream(stream)); - r - } - None => doc.add(Object::Stream(stream)), - }; - let mut fresh = Dict::new(); - fresh.set(b"N".to_vec(), Object::Ref(ap)); - let apd = merged_appearance(doc, w.obj, &fresh); - doc.update_dict(w.obj, |d| { - d.set(b"AP".to_vec(), Object::Dict(apd)); - d.remove(b"AS"); - pdfcraft_fonts::set_undrawn_mark(d, missing); - })?; - } - if undrawn { - ask_viewers_to_redraw(doc)?; - } - Ok(()) -} - -/// A value its widget's font can't show is stored as typed and drawn with `?` (as Acrobat keeps -/// such a value): `/NeedAppearances` then asks other viewers to draw the fields from their values -/// with fonts that have those characters (the interactive form dictionary, §12.7.3). Not in a -/// document that declares PDF/A, which forbids it: there the widget's mark -/// (`pdfcraft_fonts::UNDRAWN_MARK`) is all, and the edit's report says the `?` shows everywhere. -pub(crate) fn ask_viewers_to_redraw(doc: &mut Document) -> Result<(), FormError> { - if declares_pdfa(doc) || need_appearances(doc) { - return Ok(()); - } - let Some(root) = doc.root() else { return Ok(()) }; - let entry = doc.get(root).as_dict().and_then(|c| c.get(b"AcroForm").cloned()); - match entry { - Some(Object::Ref(r)) => doc.update_dict(r, |d| d.set(b"NeedAppearances".to_vec(), Object::Bool(true)))?, - Some(Object::Dict(mut af)) => { - af.set(b"NeedAppearances".to_vec(), Object::Bool(true)); - doc.update_dict(root, |c| c.set(b"AcroForm".to_vec(), Object::Dict(af)))?; - } - _ => {} - } - Ok(()) -} - -/// Text fields with the Password flag (inherited or their own) whose value (`/V`, inherited or -/// their own) is not empty and not their default value (`/DV`, which the form's author wrote and -/// a save restores), or one of whose separate widgets carries a `/V`. -fn password_fields(doc: &Document) -> Vec { - if !has_password_flag(doc) { - return Vec::new(); - } - let on_widget = |f: &Field| f.widgets.iter().any(|w| w.obj != f.obj && doc.get(w.obj).as_dict().is_some_and(|d| d.get(b"V").is_some())); - let typed = |f: &Field| f.value.iter().any(|v| !v.is_empty()) && f.value.first() != f.default.first(); - fields(doc).into_iter().filter(|f| f.kind == FieldKind::Text && f.has(flags::PASSWORD) && (typed(f) || on_widget(f))).collect() -} - -/// Whether any field dictionary sets the Password flag (#3943 review). A field that inherits the -/// flag has an ancestor that sets it, so that ancestor is found instead; a kid clearing the flag -/// again only makes the answer cautious. It reads only `/Ff`, `/Kids` and `/Parent`, so a save -/// or recovery snapshot of a form without such a field doesn't build every [`Field`]. Like -/// [`fields`], it looks at the form's field tree and at the widgets on the pages, which may be -/// missing from `/Fields`. Each dictionary is visited once and at most 64 levels deep. -fn has_password_flag(doc: &Document) -> bool { - let sets_it = |r: ObjRef| doc.get(r).as_dict().and_then(|d| field_flags(doc, d)).is_some_and(|ff| ff & flags::PASSWORD != 0); - let top = acroform(doc).and_then(|af| af.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned())).unwrap_or_default(); - let mut stack: Vec<(Object, usize)> = top.into_iter().map(|o| (o, 0)).collect(); - let mut seen = std::collections::HashSet::new(); - while let Some((o, depth)) = stack.pop() { - let Object::Ref(r) = o else { continue }; - if depth > 64 || !seen.insert(r) { - continue; - } - if sets_it(r) { - return true; - } - let kids = doc.get(r).as_dict().and_then(|d| d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned())).unwrap_or_default(); - stack.extend(kids.into_iter().map(|k| (k, depth + 1))); - } - // Widgets on the pages, and the fields above them. - page_refs(doc).into_iter().any(|p| { - let annots = - doc.get(p).as_dict().and_then(|d| d.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned())).unwrap_or_default(); - annots.iter().filter_map(Object::as_ref).filter(|r| !seen.contains(r)).any(|r| field_chain(doc, r).into_iter().any(sets_it)) - }) -} - -/// Whether any text field with the Password flag holds a value in `doc`. -pub fn has_password_values(doc: &Document) -> bool { - !password_fields(doc).is_empty() -} - -/// The typed values of the text fields with the Password flag, as (name, text) pairs, for -/// [`restore_password_values`] after the document was rebased on a file written without them. -pub fn password_values(doc: &Document) -> Vec<(String, String)> { - password_fields(doc).into_iter().filter_map(|f| f.value.first().map(|v| (f.name.clone(), v.clone()))).collect() -} - -/// Put values from [`password_values`] back into the open document, in memory only. A field -/// that is gone or read-only keeps what it has. -pub fn restore_password_values(doc: &mut Document, values: &[(String, String)]) -> Result<(), FormError> { - for (name, v) in values { - match set_value(doc, name, &FieldValue::Text(v.clone())) { - Ok(()) | Err(FormError::NoSuchField(_) | FormError::ReadOnly(_)) => {} - Err(e) => return Err(e), - } - } - Ok(()) -} - -/// `r` and its field ancestors (`/Parent`), nearest first, at most 64 deep and without repeats. -fn field_chain(doc: &Document, r: ObjRef) -> Vec { - let mut chain = vec![r]; - let mut cur = r; - while chain.len() < 64 { - let Some(Object::Ref(parent)) = doc.get(cur).as_dict().and_then(|d| d.get(b"Parent").cloned()) else { break }; - if chain.contains(&parent) { - break; - } - chain.push(parent); - cur = parent; - } - chain -} - -/// Remove the values of the text fields with the Password flag, for a copy of the document about -/// to be written to a file (#3926). ISO 32000-2 §12.7.5.3, Table 231, Password flag, note: "it is -/// imperative that PDF processors never store the value of the text field in the PDF file if -/// this flag is set". As Acrobat saves such a field (#3943 review, black-box test): a field with -/// a default value (`/DV`, which the form's author wrote) gets it back as its value, one without -/// is left empty, and its appearance is drawn again from that value, so the masking characters -/// no longer show how long the typed password was. `/DV` and the field's scripts stay. -/// -/// A value can sit on the field, on one of its widgets, or on an ancestor it inherits from -/// (`/V` is inheritable). Each is removed; an ancestor's only when every field inheriting from it -/// is such a password field. Where an ancestor's value is shared with other fields and stays, the -/// password field gets an empty value of its own so it no longer inherits it. Returns how many -/// fields were cleared. -pub fn clear_password_values(doc: &mut Document) -> Result { - let targets = password_fields(doc); - if targets.is_empty() { - return Ok(0); - } - let is_password = |f: &Field| f.kind == FieldKind::Text && f.has(flags::PASSWORD); - // Each field's ancestors, found once: only values change below, never `/Parent` (#3943 - // review: walking them again for every ancestor of every target was quadratic). - let chains: Vec<(bool, Vec)> = fields(doc).iter().map(|g| (is_password(g), field_chain(doc, g.obj))).collect(); - for f in &targets { - let chain = field_chain(doc, f.obj); - for w in &f.widgets { - if w.obj != f.obj { - doc.update_dict(w.obj, |d| { - d.remove(b"V"); - })?; - } - } - doc.update_dict(f.obj, |d| { - d.remove(b"V"); - })?; - let mut shared_value_kept = false; - for a in chain.iter().skip(1) { - if doc.get(*a).as_dict().is_none_or(|d| d.get(b"V").is_none()) { - continue; - } - let exclusive = chains.iter().filter(|(_, chain)| chain.contains(a)).all(|(password, _)| *password); - if exclusive { - doc.update_dict(*a, |d| { - d.remove(b"V"); - })?; - } else { - shared_value_kept = true; - } - } - let restored: Vec = f.default.first().cloned().into_iter().collect(); - match restored.first() { - Some(dv) => doc.update_dict(f.obj, |d| d.set(b"V".to_vec(), PdfString::text(dv)))?, - None if shared_value_kept => doc.update_dict(f.obj, |d| d.set(b"V".to_vec(), PdfString::text("")))?, - None => {} - } - redraw(doc, f, &restored, &mut NoScripts)?; - } - Ok(targets.len()) -} - -/// Whether the form asks viewers to draw its fields' appearances themselves (`/NeedAppearances`). -pub fn need_appearances(doc: &Document) -> bool { - acroform(doc).is_some_and(|af| matches!(af.get(b"NeedAppearances").map(|o| doc.resolve(o)).as_deref(), Some(Object::Bool(true)))) -} - -/// A copy of `doc` in which form fields are drawn the way the file leaves to the viewer, for -/// displaying the document (`None` when no widget needs it). Text and choice fields are drawn from -/// their values: every one when the form sets `/NeedAppearances` (ISO 32000-2 Table 224: the -/// writer left the appearances to the viewer), and otherwise each widget without a normal -/// appearance (`/AP /N`), as typing the value would draw it (#3470). Check boxes and radio buttons -/// with no normal appearance for their state (`/AS`) are drawn from their `/MK` background, border -/// and caption, and with `/NeedAppearances` every one that has an `/MK` is, over its own -/// appearance; push buttons with none are drawn from their `/MK` and `/BS` (Table 192). `doc` is -/// not changed: saving writes the file as it was. -pub fn with_field_appearances(doc: &Document) -> Option { - with_field_appearances_cached(doc, &mut FieldAppearanceCache::default()) -} - -/// [`with_field_appearances`], reusing the appearances `cache` drew for widgets whose inputs are -/// the same objects as then (see [`FieldAppearanceCache`]). -pub fn with_field_appearances_cached(doc: &Document, cache: &mut FieldAppearanceCache) -> Option { - let all = need_appearances(doc); - let mut copy: Option = None; - for f in fields(doc) { - for w in &f.widgets { - let widget = doc.get(w.obj); - let normal = widget - .as_dict() - .and_then(|d| d.get(b"AP")) - .and_then(|ap| doc.resolve(ap).as_dict().and_then(|ap| ap.get(b"N").map(|n| doc.resolve(n)))); - // A check box or radio button shows the appearance of its state (12.7.5.2.3): `/AS`, - // else the value when it names this widget's on state (any check box value when the - // widget names none), else Off. An Off state may go without one (it shows nothing); - // an on state needs one. - let state = match f.kind { - FieldKind::CheckBox | FieldKind::Radio => { - let mine = |v: &&String| w.on_state.as_ref().map_or(f.kind == FieldKind::CheckBox, |on| on == *v); - let value = f.value.first().filter(|v| !v.is_empty() && v.as_str() != "Off").filter(mine); - Some(w.state.clone().or_else(|| value.cloned()).unwrap_or_else(|| "Off".into())) - } - _ => None, - }; - // With /NeedAppearances a check box or radio button that describes its look in /MK is - // drawn from it (Table 192), its own appearance or not; one without /MK keeps its own. - let described = || widget.as_dict().and_then(|d| d.get(b"MK")).is_some_and(|mk| doc.resolve(mk).as_dict().is_some()); - let missing = match f.kind { - FieldKind::Text | FieldKind::Combo | FieldKind::List => normal.is_none() || all, - FieldKind::PushButton => normal.is_none(), - FieldKind::CheckBox | FieldKind::Radio => match (normal.as_deref(), state.as_deref()) { - _ if all && described() => true, - (None, _) => true, - (Some(Object::Dict(states)), Some(s)) => s != "Off" && !states.contains(&name_bytes(s)), - _ => false, - }, - FieldKind::Signature => false, - }; - if !missing { - continue; - } - let inputs = appearance_inputs(doc, w); - let stream = match cache.get(w.obj, &inputs) { - Some(stream) => stream, - None => { - let stream = match f.kind { - FieldKind::CheckBox | FieldKind::Radio => appearance::button_state_shown(doc, &f, w, state.as_deref() != Some("Off")), - FieldKind::PushButton => appearance::push_button_shown(doc, &f, w), - _ => appearance::field_appearance_drawn(doc, &f, w, &f.value, true).0, - }; - cache.insert(w.obj, inputs, &stream); - stream - } - }; - let target = copy.get_or_insert_with(|| doc.clone()); - let ap = target.add(Object::Stream(stream)); - let mut fresh = Dict::new(); - match &state { - // The drawn look under its state's name, and that state as the widget's own. - Some(s) => { - let mut states = Dict::new(); - states.set(name_bytes(s), Object::Ref(ap)); - fresh.set(b"N".to_vec(), Object::Dict(states)); - } - None => fresh.set(b"N".to_vec(), Object::Ref(ap)), - } - let apd = merged_appearance(target, w.obj, &fresh); - // Fails only when the widget isn't a dictionary; that widget then keeps its own look. - target - .update_dict(w.obj, |d| { - d.set(b"AP".to_vec(), Object::Dict(apd)); - if let Some(s) = &state { - d.set(b"AS".to_vec(), Object::Name(name_bytes(s))); - } - }) - .ok(); - } - } - copy -} - -/// The objects a widget's drawn appearance is built from, as the document holds them: the widget -/// and the fields above it (value, `/DA`, `/Q`, flags, options, `/MK`, formatting actions), the -/// form dictionary (`/DA`) and the fonts of its `/DR`, each with the objects its entries refer to -/// (an indirect `/MK` or `/V`). Not the page: a field's rotation is its own `/MK /R`. -fn appearance_inputs(doc: &Document, w: &Widget) -> Vec> { - // The objects a dictionary's entries refer to. The tree links and the drawn appearance itself - // are not inputs, and `/Kids` and `/Fields` can be long. - let referred = |d: &Dict, inputs: &mut Vec>| { - for (k, v) in d.iter() { - if !matches!(&k[..], b"Kids" | b"Fields" | b"P" | b"Parent" | b"AP") - && let Some(r) = v.as_ref() - { - inputs.push(doc.get(r)); - } - } - }; - let mut inputs = Vec::new(); - let mut seen = std::collections::HashSet::new(); - let mut node = Some(w.obj); - // The /Parent chain; the seen-set and the cap end a cycle or an absurd depth. - while let Some(r) = node.filter(|r| seen.insert(*r) && seen.len() <= 64) { - let obj = doc.get(r); - if let Some(d) = obj.as_dict() { - node = d.reference(b"Parent"); - referred(d, &mut inputs); - } else { - node = None; - } - inputs.push(obj); - } - let Some(root) = doc.root() else { return inputs }; - let catalog = doc.get(root); - let form = match catalog.as_dict().and_then(|c| c.reference(b"AcroForm")) { - Some(r) => doc.get(r), - None => catalog, - }; - if let Some(f) = form.as_dict() { - referred(f, &mut inputs); - // The fonts of /DR. An indirect /DR or /Font is listed through the entries that refer to - // it; a direct one is part of the object that holds it. `held` only finds them: a copy - // of a direct object is a new one each time, never an input. - let held = |o: &Object| o.as_ref().map_or_else(|| std::sync::Arc::new(o.clone()), |r| doc.get(r)); - if let Some(dr) = f.get(b"DR").map(held) - && let Some(dr) = dr.as_dict() - { - referred(dr, &mut inputs); - if let Some(font) = dr.get(b"Font").map(held) - && let Some(fonts) = font.as_dict() - { - referred(fonts, &mut inputs); - } - } - } - inputs.push(form); - inputs -} - -/// Field appearances drawn for the display copy ([`with_field_appearances_cached`]), so a refresh -/// redraws only the widgets whose inputs changed: a form that leaves every appearance to the -/// viewer otherwise redrew all of its fields on each keystroke in any one of them (#3470). An -/// entry holds the input objects it was drawn from and is used only while each is still the same -/// object (`Arc::ptr_eq`); the document replaces an object it edits, so a changed input is a -/// miss, never a stale hit. -#[derive(Clone, Debug, Default)] -pub struct FieldAppearanceCache { - entries: std::collections::HashMap>, pdfcraft_cos::Stream)>, - bytes: usize, - hits: u64, - builds: u64, -} - -impl FieldAppearanceCache { - /// Cached appearance data restarts past this instead of growing without bound. - const MAX_BYTES: usize = 64 << 20; - - /// Forget every entry. - pub fn clear(&mut self) { - *self = Self::default(); - } - - fn get(&mut self, w: ObjRef, inputs: &[std::sync::Arc]) -> Option { - let (from, stream) = self.entries.get(&w)?; - if from.len() != inputs.len() || !from.iter().zip(inputs).all(|(a, b)| std::sync::Arc::ptr_eq(a, b)) { - return None; - } - self.hits += 1; - Some(stream.clone()) - } - - fn insert(&mut self, w: ObjRef, inputs: Vec>, stream: &pdfcraft_cos::Stream) { - self.builds += 1; - if self.bytes.saturating_add(stream.raw.len()) > Self::MAX_BYTES { - self.clear(); - } - self.bytes = self.bytes.saturating_add(stream.raw.len()); - if let Some((_, old)) = self.entries.insert(w, (inputs, stream.clone())) { - self.bytes = self.bytes.saturating_sub(old.raw.len()); - } - } - - /// Cache hits so far (reused appearances); for tests. - pub fn hits(&self) -> u64 { - self.hits - } - - /// Appearances drawn so far; for tests. - pub fn builds(&self) -> u64 { - self.builds - } -} - -/// Whether the document's XMP metadata declares PDF/A conformance (`pdfaid:part`). -pub fn declares_pdfa(doc: &Document) -> bool { - let Some(root) = doc.root() else { return false }; - let Some(m) = doc.get(root).as_dict().and_then(|c| c.get(b"Metadata").cloned()) else { return false }; - let m = doc.resolve(&m); - match &*m { - Object::Stream(s) => s.decoded().is_ok_and(|b| b.windows(11).any(|w| w == b"pdfaid:part")), - _ => false, - } -} - -/// Every field with a widget whose appearance PdfCraft drew with `?` for a character its font -/// can't hold (`pdfcraft_fonts::UNDRAWN_MARK`): the field's name and that character, once per -/// field. -pub fn undrawn_fields(doc: &Document) -> Vec<(String, char)> { - fields(doc) - .into_iter() - .filter_map(|f| { - let c = f.widgets.iter().find_map(|w| doc.get(w.obj).as_dict().and_then(pdfcraft_fonts::undrawn_mark))?; - Some((f.name, c)) - }) - .collect() -} - -/// Acrobat's Clear form: every field (or the named ones) back to its default value. -pub fn reset(doc: &mut Document, names: Option<&[String]>) -> Result { - let all = fields(doc); - if all.is_empty() { - return Err(FormError::NoForm); - } - if let Some(n) = names - && let Some(missing) = n.iter().find(|n| !all.iter().any(|f| &f.name == *n)) - { - return Err(FormError::NoSuchField(missing.clone())); - } - let mut changed = 0; - for f in all.iter().filter(|f| names.is_none_or(|n| n.contains(&f.name))) { - if f.value == f.default { - continue; - } - let v = match f.kind { - FieldKind::Text => FieldValue::Text(f.default.first().cloned().unwrap_or_default()), - FieldKind::CheckBox => FieldValue::Check(!f.default.is_empty()), - FieldKind::Radio => FieldValue::Radio(f.default.first().cloned()), - FieldKind::Combo | FieldKind::List => FieldValue::Choice(f.default.clone()), - FieldKind::PushButton | FieldKind::Signature => continue, - }; - // A reset is not blocked by NoToggleToOff: go through the writer directly. - let mut tmp = f.clone(); - tmp.flags &= !flags::NO_TOGGLE_TO_OFF; - write_value(doc, &tmp, &v, &mut NoScripts)?; - changed += 1; - } - recalculate(doc)?; - Ok(changed) -} - -/// Order tabs manually (Acrobat: Fields ▸ Tab Order ▸ Order Tabs Manually, then drag a field): -/// move field `name` one place earlier or later in its page's tab order. The page switches to -/// annotation order (`/Tabs` removed) and its widgets are reordered in `/Annots`; other -/// annotations keep their places. -pub fn move_in_tab_order(doc: &mut Document, name: &str, earlier: bool) -> Result<(), FormError> { - let all = fields(doc); - let f = all.iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?; - let page = f.widgets.iter().filter_map(|w| w.page).min().ok_or_else(|| FormError::Invalid(format!("{name} is not on a page")))?; - // The page's fields in their current tab order (a field counts once, at its first widget). - let mut order: Vec<(usize, &str)> = - all.iter().filter_map(|g| g.widgets.iter().filter(|w| w.page == Some(page)).map(|w| w.tab).min().map(|t| (t, g.name.as_str()))).collect(); - order.sort(); - let names: Vec<&str> = order.iter().map(|(_, n)| *n).collect(); - let Some(i) = names.iter().position(|n| *n == name) else { return invalid(format!("{name} is not on a page")) }; - let j = if earlier { i.checked_sub(1) } else { Some(i + 1).filter(|j| *j < names.len()) }; - let Some(j) = j else { return Ok(()) }; - let mut names = names.into_iter().map(str::to_string).collect::>(); - names.swap(i, j); - // The widgets of this page in the new order. - let mut widgets: Vec = Vec::new(); - for n in &names { - if let Some(g) = all.iter().find(|g| &g.name == n) { - widgets.extend(g.widgets.iter().filter(|w| w.page == Some(page)).map(|w| w.obj)); - } - } - let pr = *page_refs(doc).get(page).ok_or_else(|| FormError::Invalid("no such page".into()))?; - let annots_obj = - doc.get(pr).as_dict().and_then(|d| d.get(b"Annots").cloned()).ok_or_else(|| FormError::Invalid("the page has no annotations".into()))?; - let list = doc.resolve(&annots_obj).as_array().cloned().unwrap_or_default(); - let mut next = widgets.into_iter(); - let set: std::collections::HashSet = all.iter().flat_map(|g| g.widgets.iter().filter(|w| w.page == Some(page)).map(|w| w.obj)).collect(); - let new_list: Vec = list - .iter() - .map(|o| match o.as_ref() { - Some(r) if set.contains(&r) => next.next().map(Object::Ref).unwrap_or_else(|| o.clone()), - _ => o.clone(), - }) - .collect(); - match annots_obj { - Object::Ref(ar) => doc.set(ar, Object::Array(new_list)), - _ => doc.update_dict(pr, |d| d.set(b"Annots".to_vec(), Object::Array(new_list)))?, - } - doc.update_dict(pr, |d| { - d.remove(b"Tabs"); - })?; - Ok(()) -} diff --git a/crates/forms/src/read.rs b/crates/forms/src/read.rs new file mode 100644 index 0000000000..e689c0e813 --- /dev/null +++ b/crates/forms/src/read.rs @@ -0,0 +1,568 @@ +use super::tab_order::rank_tabs; +use super::{DecodeBudget, Dict, Document, Field, FieldKind, ObjRef, Object, PdfString, Widget, af, appearance, flags, name_text, text_of}; + +/// At most this many `/State` entries of a set-layer-visibility action are read. +const MAX_LAYER_STATE: usize = 1024; + +/// A push button's mouse-up action (`/A`, or `/AA /U`, on the field or its widget). +fn button_action(doc: &Document, d: &Dict, widgets: &[ObjRef], scripts: &mut DecodeBudget) -> Option { + // A button PdfCraft generated for an XFA image field (`/PCXfaImage true`, no PDF action): + // clicking it picks a picture, as `buttonImportIcon()` does. + if matches!(d.get(b"PCXfaImage").map(|o| doc.resolve(o)).as_deref(), Some(Object::Bool(true))) { + return Some(af::ButtonAction::ImportIcon); + } + let pick = |dict: &Dict| -> Option { + dict.get(b"A").cloned().or_else(|| dict.get(b"AA").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().and_then(|aa| aa.get(b"U").cloned()))) + }; + let action = pick(d).or_else(|| widgets.iter().find_map(|w| doc.get(*w).as_dict().and_then(pick)))?; + let a = doc.resolve(&action); + let a = a.as_dict()?; + let s = |k: &[u8]| a.get(k).and_then(|v| text_of(&doc.resolve(v))); + Some(match a.name(b"S")? { + b"ResetForm" => { + let fields = a + .get(b"Fields") + .map(|f| doc.resolve(f)) + .and_then(|f| f.as_array().cloned()) + .unwrap_or_default() + .iter() + .filter_map(|o| match o { + Object::Ref(r) => doc.get(*r).as_dict().and_then(|fd| fd.get(b"T").and_then(|t| text_of(&doc.resolve(t)))), + other => text_of(&doc.resolve(other)), + }) + .collect(); + let exclude = a.get(b"Flags").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0) & 1 != 0; + af::ButtonAction::Reset { fields, exclude } + } + b"Named" => af::ButtonAction::Named(String::from_utf8_lossy(a.name(b"N")?).into_owned()), + b"URI" => af::ButtonAction::Uri(s(b"URI")?), + b"SubmitForm" => af::ButtonAction::Submit( + a.get(b"F") + .map(|f| doc.resolve(f)) + .and_then(|f| f.as_dict().and_then(|fd| fd.get(b"F").and_then(|x| text_of(&doc.resolve(x)))).or_else(|| text_of(&f))) + .unwrap_or_default(), + ), + b"GoTo" => { + let dest = doc.resolve(a.get(b"D")?); + let target = dest.as_array()?.first()?.as_ref()?; + af::ButtonAction::GoTo(page_refs(doc).iter().position(|p| *p == target)?) + } + b"Hide" => { + // /T: a field name, an annotation (or field) reference, or an array of them. + let target = |o: &Object| match o { + Object::Ref(r) => qualified_name(doc, *r).or_else(|| text_of(&doc.resolve(o))), + other => text_of(other), + }; + let fields = match a.get(b"T") { + Some(t) => match &*doc.resolve(t) { + Object::Array(items) => items.iter().filter_map(target).collect(), + _ => target(t).into_iter().collect(), + }, + None => Vec::new(), + }; + let hide = !a.get(b"H").is_some_and(|h| matches!(&*doc.resolve(h), Object::Bool(false))); + af::ButtonAction::ShowHide { fields, hide } + } + b"SetOCGState" => { + // /State: ON, OFF or Toggle, each followed by the groups it applies to. + let mut changes = Vec::new(); + if let Some(state) = a.get(b"State") { + let mut op = None; + for item in doc.resolve(state).as_array().into_iter().flatten().take(MAX_LAYER_STATE) { + match item { + Object::Name(n) => { + op = match n.as_slice() { + b"ON" => Some(af::LayerOp::On), + b"OFF" => Some(af::LayerOp::Off), + b"Toggle" => Some(af::LayerOp::Toggle), + _ => None, + } + } + Object::Ref(r) => changes.extend(op.map(|op| (op, (r.num, r.generation)))), + _ => {} + } + } + } + let preserve_rb = !a.get(b"PreserveRB").is_some_and(|p| matches!(&*doc.resolve(p), Object::Bool(false))); + af::ButtonAction::SetLayers { changes, preserve_rb } + } + b"JavaScript" => af::button_script(&script(doc, &action, scripts)?), + _ => return None, + }) +} + +/// The fully qualified name of the field that `r` (a field or one of its widgets) belongs to, +/// from the partial names (`/T`) up its `/Parent` chain. +fn qualified_name(doc: &Document, r: ObjRef) -> Option { + let mut parts = Vec::new(); + let mut seen = std::collections::HashSet::new(); + let mut cur = Some(r); + while let Some(c) = cur { + if seen.len() > 64 || !seen.insert(c) { + break; + } + let obj = doc.get(c); + let d = obj.as_dict()?; + if let Some(t) = d.get(b"T").and_then(|o| text_of(&doc.resolve(o))) { + parts.push(t); + } + cur = d.get(b"Parent").and_then(|p| p.as_ref()); + } + parts.reverse(); + (!parts.is_empty()).then(|| parts.join(".")) +} + +/// JavaScript streams one reading of the form (its fields, or its document scripts) may decode +/// in all, however many actions name the same stream. +pub(crate) const MAX_SCRIPT_BYTES: usize = 64 << 20; + +/// A budget for the script streams of one reading of the form. +pub(crate) fn script_budget() -> DecodeBudget { + DecodeBudget::new(MAX_SCRIPT_BYTES, 0) +} + +/// The JavaScript of an action dictionary (`/JS` string or stream). A stream is decoded against +/// `budget`, and is `None` once it is spent. +pub(crate) fn script(doc: &Document, action: &Object, budget: &mut DecodeBudget) -> Option { + let a = doc.resolve(action); + let js = a.as_dict()?.get(b"JS")?.clone(); + match &*doc.resolve(&js) { + Object::String(s) => Some(s.to_text()), + Object::Stream(s) => budget.decode(s).ok().map(|b| String::from_utf8_lossy(&b).into_owned()), + _ => None, + } +} + +/// A field's format/keystroke, validate and calculate scripts (field dictionary, or its only +/// widget's when the field has none). +fn actions_of(doc: &Document, d: &Dict, widgets: &[ObjRef], scripts: &mut DecodeBudget) -> af::Actions { + let aa = d.get(b"AA").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()).or_else(|| { + (widgets.len() == 1) + .then(|| doc.get(widgets[0])) + .and_then(|w| w.as_dict().and_then(|wd| wd.get(b"AA").map(|a| doc.resolve(a)))) + .and_then(|a| a.as_dict().cloned()) + }); + let mut out = af::Actions::default(); + let Some(aa) = aa else { return out }; + let mut js = |k: &[u8]| aa.get(k).and_then(|a| script(doc, a, scripts)); + if let Some(f) = js(b"F") { + match af::parse_format(&f) { + Some(fm) => out.format = fm, + None if !f.trim().is_empty() => { + out.unsupported.push("format"); + out.scripts.format = Some(f); + } + None => {} + } + } + if let Some(k) = js(b"K") + && out.format == af::Format::None + { + // A mask has only a keystroke script. + match af::parse_format(&k) { + Some(fm @ af::Format::Mask(_)) => out.format = fm, + Some(_) => {} + None if !k.trim().is_empty() => { + out.unsupported.push("keystroke"); + out.scripts.keystroke = Some(k); + } + None => {} + } + } + if let Some(v) = js(b"V") { + match af::parse_validate(&v) { + Some(vv) => out.validate = vv, + None if !v.trim().is_empty() => { + out.unsupported.push("validate"); + out.scripts.validate = Some(v); + } + None => {} + } + } + if let Some(c) = js(b"C") { + match af::parse_calculate(&c) { + Some(cc) => out.calculate = cc, + None if !c.trim().is_empty() => { + out.unsupported.push("calculate"); + out.scripts.calculate = Some(c); + } + None => {} + } + } + out +} + +fn values_of(doc: &Document, o: Option<&Object>) -> Vec { + let Some(o) = o else { return Vec::new() }; + match &*doc.resolve(o) { + Object::Array(a) => a.iter().filter_map(|x| text_of(&doc.resolve(x))).collect(), + Object::Stream(s) => s.decoded().ok().map(|b| PdfString::literal(b).to_text()).into_iter().collect(), + other => text_of(other).filter(|s| s != "Off" && !s.is_empty()).into_iter().collect(), + } +} + +fn nums(doc: &Document, o: Option<&Object>) -> Option> { + let o = doc.resolve(o?); + o.as_array()?.iter().map(|x| doc.resolve(x).as_f64()).collect() +} + +/// Leaf page objects in order (a small walker; `organize` and `annot` have their own). +pub(crate) fn page_refs(doc: &Document) -> Vec { + let Some(root) = doc.root() else { return Vec::new() }; + let Some(pages) = doc.get(root).as_dict().and_then(|d| d.reference(b"Pages")) else { return Vec::new() }; + PageWalk::new(doc, pages).collect() +} + +/// Depth-first walk over the leaf pages under `top`. It keeps one cursor per open `/Kids` array, +/// never one entry per child, so a child listed many times or shared between nodes is skipped by +/// the seen check without being queued first (`pdfcraft_model::pages` walks the same way). +/// Cycles end at the seen check and at most a million nodes are visited. +pub(crate) struct PageWalk<'a> { + doc: &'a Document, + seen: std::collections::HashSet, + pub(crate) pending: Vec<(std::sync::Arc, usize)>, +} + +impl<'a> PageWalk<'a> { + pub(crate) fn new(doc: &'a Document, top: ObjRef) -> Self { + let top = std::sync::Arc::new(Object::Array(vec![Object::Ref(top)])); + Self { doc, seen: Default::default(), pending: vec![(top, 0)] } + } +} + +impl Iterator for PageWalk<'_> { + type Item = ObjRef; + + fn next(&mut self) -> Option { + while let Some((kids, next)) = self.pending.last_mut() { + let Some(kid) = kids.as_array().and_then(|a| a.get(*next)) else { + self.pending.pop(); + continue; + }; + let node = kid.as_ref(); + *next += 1; + let Some(node) = node else { continue }; + if self.seen.len() >= 1_000_000 { + self.pending.clear(); + return None; + } + if !self.seen.insert(node) { + continue; + } + let obj = self.doc.get(node); + let Some(d) = obj.as_dict() else { continue }; + match d.get(b"Kids").map(|k| self.doc.resolve(k)) { + Some(kids) if d.name(b"Type") != Some(b"Page") => self.pending.push((kids, 0)), + _ => return Some(node), + } + } + None + } +} + +/// The AcroForm dictionary. +pub fn acroform(doc: &Document) -> Option { + let root = doc.root()?; + let cat = doc.get(root); + let af = cat.as_dict()?.get(b"AcroForm")?.clone(); + doc.resolve(&af).as_dict().cloned() +} + +#[derive(Clone, Default)] +struct Inherited { + name: String, + ft: Option>, + ff: Option, + v: Option, + dv: Option, + da: Option, + q: Option, + max_len: Option, +} + +/// Every terminal field: the `/Fields` tree in order, then the fields reachable only through the +/// page annotations (see [`adopt_page_fields`]). +pub fn fields(doc: &Document) -> Vec { + fields_with_adopted(doc).items +} + +/// How many of [`fields`] were reachable only through the page annotations: the form's `/Fields` +/// list names none or only some of them, so the leniency that adopts them is worth recording. +pub fn adopted_page_fields(doc: &Document) -> usize { + fields_with_adopted(doc).adopted_page_fields +} + +/// The terminal fields and page-only fields adopted while reading a form. +#[derive(Clone, Debug, PartialEq)] +pub struct Fields { + /// Terminal fields in tree order, followed by adopted page-only fields. + pub items: Vec, + /// How many fields were reachable only through page annotations. + pub adopted_page_fields: usize, +} + +/// Read all terminal fields and the page-only adoption count in one enumeration. +pub fn fields_with_adopted(doc: &Document) -> Fields { + let (items, adopted_page_fields) = enumerate(doc); + Fields { items, adopted_page_fields } +} + +#[cfg(test)] +thread_local! { + /// Whole-form readings ([`enumerate`]) on this thread. + static FORM_READS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// How many times this thread has read the whole form. +#[cfg(test)] +pub(crate) fn form_reads() -> usize { + FORM_READS.with(std::cell::Cell::get) +} + +pub(crate) fn enumerate(doc: &Document) -> (Vec, usize) { + #[cfg(test)] + FORM_READS.with(|n| n.set(n.get() + 1)); + let af = acroform(doc); + let mut page_of = std::collections::HashMap::new(); + let mut annot_index = std::collections::HashMap::new(); + let pages = page_refs(doc); + for (i, p) in pages.iter().enumerate() { + if let Some(a) = doc.get(*p).as_dict().and_then(|d| d.get(b"Annots").cloned()) { + for (k, e) in doc.resolve(&a).as_array().into_iter().flatten().enumerate() { + if let Some(r) = e.as_ref() { + page_of.entry(r).or_insert(i); + annot_index.entry(r).or_insert(k); + } + } + } + } + let base = match &af { + Some(af) => Inherited { + da: af.get(b"DA").and_then(|o| text_of(&doc.resolve(o))), + q: af.get(b"Q").and_then(|o| doc.resolve(o).as_int()), + ..Default::default() + }, + None => Inherited::default(), + }; + let mut out = Vec::new(); + let mut seen = Seen { fields: std::collections::HashSet::new(), widgets: std::collections::HashSet::new(), scripts: script_budget() }; + if let Some(af) = &af { + for f in af.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default() { + if let Some(r) = f.as_ref() { + walk(doc, r, &base, &page_of, &mut seen, &mut out, 0); + } + } + } + let listed = out.len(); + adopt_page_fields(doc, &pages, &base, &page_of, &mut seen, &mut out); + let adopted = out.len() - listed; + rank_tabs(doc, &pages, &annot_index, &mut out); + (out, adopted) +} + +/// Widget annotations no `/Fields` entry reaches are adopted as fields, as Acrobat and the +/// browsers do: some writers list only some of the fields, or none at all. Each widget is adopted +/// through its topmost unlisted `/Parent`, so a field split across several widgets stays one +/// field. Tree-listed fields keep their order first; the rest follow in page-annotation order. +fn adopt_page_fields( + doc: &Document, + pages: &[ObjRef], + base: &Inherited, + page_of: &std::collections::HashMap, + seen: &mut Seen, + out: &mut Vec, +) { + for p in pages { + let Some(a) = doc.get(*p).as_dict().and_then(|d| d.get(b"Annots").cloned()) else { continue }; + let annots = doc.resolve(&a); + let Some(annots) = annots.as_array() else { continue }; + for e in annots { + let Some(r) = e.as_ref() else { continue }; + if seen.fields.contains(&r) || seen.widgets.contains(&r) { + continue; + } + let obj = doc.get(r); + let Some(d) = obj.as_dict() else { continue }; + if d.name(b"Subtype") != Some(b"Widget") { + continue; + } + if !d.contains(b"FT") && !d.contains(b"T") && !d.contains(b"Parent") { + continue; + } + if let Some(root) = unlisted_root(doc, r, &seen.fields) { + walk(doc, root, base, page_of, seen, out, 0); + } + } + } +} + +/// The topmost field above `r` (itself without a `/Parent`), or `None` when `r` or an ancestor is +/// already reachable from the `/Fields` tree, or a `/Parent` loop hides the top. +fn unlisted_root(doc: &Document, r: ObjRef, seen: &std::collections::HashSet) -> Option { + let mut top = r; + let mut visited = std::collections::HashSet::from([r]); + loop { + let parent = doc.get(top).as_dict().and_then(|d| d.reference(b"Parent")); + let Some(p) = parent else { + return if seen.contains(&top) { None } else { Some(top) }; + }; + if !visited.insert(p) || visited.len() > 64 { + return None; + } + if seen.contains(&p) { + return None; + } + top = p; + } +} + +/// What one reading of the form's fields has used: the field and widget dictionaries read, and +/// the script budget their actions share. +struct Seen { + fields: std::collections::HashSet, + /// A widget belongs to the first field that lists it, however often it is listed. + widgets: std::collections::HashSet, + scripts: DecodeBudget, +} + +/// Read the field at `r` and the fields under it into `out`, with what they inherit from +/// `parent`: kids with a name are fields of their own, kids without are this field's widgets. +fn walk( + doc: &Document, + r: ObjRef, + parent: &Inherited, + page_of: &std::collections::HashMap, + seen: &mut Seen, + out: &mut Vec, + depth: usize, +) { + if depth > 64 || !seen.fields.insert(r) { + return; + } + let obj = doc.get(r); + let Some(d) = obj.as_dict() else { return }; + let mut inh = parent.clone(); + if let Some(t) = d.get(b"T").and_then(|o| text_of(&doc.resolve(o))) { + inh.name = if inh.name.is_empty() { t } else { format!("{}.{t}", inh.name) }; + } + if let Some(ft) = d.name(b"FT") { + inh.ft = Some(ft.to_vec()); + } + if let Some(ff) = field_flags(doc, d) { + inh.ff = Some(ff); + } + for (k, slot) in [(&b"V"[..], &mut inh.v), (b"DV", &mut inh.dv)] { + if let Some(v) = d.get(k) { + *slot = Some(v.clone()); + } + } + if let Some(da) = d.get(b"DA").and_then(|o| text_of(&doc.resolve(o))) { + inh.da = Some(da); + } + if let Some(q) = d.get(b"Q").and_then(|o| doc.resolve(o).as_int()) { + inh.q = Some(q); + } + if let Some(m) = d.get(b"MaxLen").and_then(|o| doc.resolve(o).as_int()) { + // Compatibility: some writers emit zero for an unlimited text field. Keep /MaxLen + // untouched in the object graph, but expose only positive limits to both UI and API. + // An explicit zero also clears an inherited limit rather than inheriting it again. + inh.max_len = usize::try_from(m).ok().filter(|limit| *limit > 0); + } + let kids: Vec = + d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default().iter().filter_map(|k| k.as_ref()).collect(); + // Kids with /T are fields; kids without are this field's widgets. + let field_kids: Vec = kids.iter().copied().filter(|k| doc.get(*k).as_dict().is_some_and(|kd| kd.contains(b"T"))).collect(); + if !field_kids.is_empty() { + for k in field_kids { + walk(doc, k, &inh, page_of, seen, out, depth + 1); + } + return; + } + // A field of no kind this reads is skipped before it claims widgets or scripts, which a later + // field listing them then reads. + let ff = inh.ff.unwrap_or(0); + let kind = match inh.ft.as_deref() { + Some(b"Tx") => FieldKind::Text, + Some(b"Btn") if ff & flags::PUSH_BUTTON != 0 => FieldKind::PushButton, + Some(b"Btn") if ff & flags::RADIO != 0 => FieldKind::Radio, + Some(b"Btn") => FieldKind::CheckBox, + Some(b"Ch") if ff & flags::COMBO != 0 => FieldKind::Combo, + Some(b"Ch") => FieldKind::List, + Some(b"Sig") => FieldKind::Signature, + _ => return, + }; + let widget_refs: Vec = if d.contains(b"Rect") { vec![r] } else { kids }; + let widget_refs: Vec = widget_refs.into_iter().filter(|w| seen.widgets.insert(*w)).collect(); + let actions = actions_of(doc, d, &widget_refs, &mut seen.scripts); + let button = (inh.ft.as_deref() == Some(b"Btn") && inh.ff.unwrap_or(0) & flags::PUSH_BUTTON != 0) + .then(|| button_action(doc, d, &widget_refs, &mut seen.scripts)) + .flatten(); + let widgets = widget_refs + .into_iter() + .filter_map(|w| { + let wo = doc.get(w); + let wd = wo.as_dict()?; + let rect = nums(doc, wd.get(b"Rect")).filter(|r| r.len() == 4).unwrap_or_else(|| vec![0.0; 4]); + let annot_flags = wd.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0); + let own_ff = if w == r { 0 } else { field_flags(doc, wd).unwrap_or(0) }; + let on_state = wd + .get(b"AP") + .map(|ap| doc.resolve(ap)) + .and_then(|ap| ap.as_dict().and_then(|a| a.get(b"N").cloned())) + .and_then(|n| doc.resolve(&n).as_dict().cloned()) + .and_then(|n| n.iter().map(|(k, _)| name_text(k)).find(|k| k != "Off")); + Some(Widget { + obj: w, + page: page_of.get(&w).copied(), + rect: [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])], + on_state: on_state.filter(|_| matches!(kind, FieldKind::CheckBox | FieldKind::Radio)), + state: wd.name(b"AS").map(name_text), + tab: usize::MAX, + locked: annot_flags & 128 != 0, + hidden: annot_flags & (2 | 32) != 0, + read_only: (ff | own_ff) & flags::READ_ONLY != 0, + rotation: appearance::mk_rotation(doc, wd), + caption: (kind == FieldKind::PushButton) + .then(|| { + let mk = doc.resolve(wd.get(b"MK")?); + let value = doc.resolve(mk.as_dict()?.get(b"CA")?); + value.as_string().map(|s| s.to_text()) + }) + .flatten(), + list: (kind == FieldKind::List).then(|| appearance::list_rows_of(doc, inh.da.as_deref().unwrap_or("/Helv 0 Tf 0 g"), Some(d), wd)), + round: kind == FieldKind::Radio || (kind == FieldKind::CheckBox && appearance::is_round(doc, wd)), + }) + }) + .collect(); + let options = d + .get(b"Opt") + .map(|o| doc.resolve(o)) + .and_then(|o| o.as_array().cloned()) + .unwrap_or_default() + .iter() + .filter_map(|o| match &*doc.resolve(o) { + Object::Array(pair) if pair.len() >= 2 => Some((text_of(&doc.resolve(&pair[0]))?, text_of(&doc.resolve(&pair[1]))?)), + other => text_of(other).map(|t| (t.clone(), t)), + }) + .collect(); + out.push(Field { + name: inh.name.clone(), + obj: r, + kind, + value: values_of(doc, inh.v.as_ref()), + default: values_of(doc, inh.dv.as_ref()), + flags: ff, + max_len: inh.max_len, + options, + da: inh.da.clone().unwrap_or_else(|| "/Helv 0 Tf 0 g".into()), + quadding: inh.q.unwrap_or(0), + tooltip: d.get(b"TU").and_then(|o| text_of(&doc.resolve(o))), + widgets, + actions, + button, + }); +} + +pub(crate) fn field_flags(doc: &Document, dict: &Dict) -> Option { + dict.get(b"Ff").and_then(|value| doc.resolve(value).as_int()).and_then(|value| u32::try_from(value).ok()) +} diff --git a/crates/forms/src/tab_order.rs b/crates/forms/src/tab_order.rs new file mode 100644 index 0000000000..5b60e3927b --- /dev/null +++ b/crates/forms/src/tab_order.rs @@ -0,0 +1,180 @@ +use super::read::{fields, page_refs}; +use super::{Document, Field, FormError, ObjRef, Object, invalid}; + +/// A page's tab order (`/Tabs`). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TabOrder { + /// Rows, top to bottom, left to right within a row (`/R`; right to left in a document bound + /// on the right). + Row, + /// Columns, left to right, top to bottom within a column (`/C`; columns right to left in a + /// document bound on the right). + Column, + /// The document structure (`/S`; annotation order when the file has none). + Structure, + /// Unspecified: annotation order. + Annotations, +} + +impl TabOrder { + fn of(name: Option<&[u8]>) -> TabOrder { + match name { + Some(b"R") => TabOrder::Row, + Some(b"C") => TabOrder::Column, + Some(b"S") => TabOrder::Structure, + _ => TabOrder::Annotations, + } + } + + /// The tab order of the page at `r`, with an indirect `/Tabs` resolved. + fn of_page(doc: &Document, r: ObjRef) -> TabOrder { + let tabs = doc.get(r).as_dict().and_then(|d| d.get(b"Tabs")).map(|o| doc.resolve(o)); + TabOrder::of(tabs.as_deref().and_then(Object::as_name)) + } +} + +/// A widget on a page for tab ordering: (page, field, widget, rect, annotation index). +type TabItem = (usize, usize, usize, [f64; 4], usize); + +/// Every widget's place in the tab order. +pub(crate) fn rank_tabs(doc: &Document, pages: &[ObjRef], annot_index: &std::collections::HashMap, out: &mut [Field]) { + let mut items: Vec = Vec::new(); + for (fi, f) in out.iter().enumerate() { + for (wi, w) in f.widgets.iter().enumerate() { + if let Some(p) = w.page { + items.push((p, fi, wi, w.rect, annot_index.get(&w.obj).copied().unwrap_or(usize::MAX))); + } + } + } + // Rows and columns run right to left in a document bound on the right: rank the mirrored + // rectangles there. + if right_to_left(doc) { + for i in &mut items { + i.3 = [-i.3[2], i.3[1], -i.3[0], i.3[3]]; + } + } + let mut ordered: Vec<(usize, usize)> = Vec::with_capacity(items.len()); + for (pi, p) in pages.iter().enumerate() { + let mut on: Vec<_> = items.iter().filter(|i| i.0 == pi).collect(); + if on.is_empty() { + continue; + } + let tabs = TabOrder::of_page(doc, *p); + match tabs { + TabOrder::Row | TabOrder::Column => { + let row = tabs == TabOrder::Row; + // Primary axis: rows by top edge (down the page), columns by left edge. + on.sort_by(|a, b| if row { b.3[3].total_cmp(&a.3[3]) } else { a.3[0].total_cmp(&b.3[0]) }); + let mut groups: Vec> = Vec::new(); + for it in on { + let fits = groups.last().is_some_and(|g| { + let first = g[0]; + if row { + (first.3[3] - it.3[3]).abs() < ((first.3[3] - first.3[1]) / 2.0).max(2.0) + } else { + (it.3[0] - first.3[0]).abs() < ((first.3[2] - first.3[0]) / 2.0).max(2.0) + } + }); + if fits && let Some(g) = groups.last_mut() { + g.push(it); + } else { + groups.push(vec![it]); + } + } + for mut g in groups { + g.sort_by(|a, b| if row { a.3[0].total_cmp(&b.3[0]) } else { b.3[3].total_cmp(&a.3[3]) }); + ordered.extend(g.iter().map(|i| (i.1, i.2))); + } + } + TabOrder::Structure | TabOrder::Annotations => { + on.sort_by_key(|i| i.4); + ordered.extend(on.iter().map(|i| (i.1, i.2))); + } + } + } + for f in out.iter_mut() { + for w in &mut f.widgets { + w.tab = usize::MAX; + } + } + for (rank, (fi, wi)) in ordered.into_iter().enumerate() { + out[fi].widgets[wi].tab = rank; + } +} + +/// Whether the document is bound on the right (`/ViewerPreferences /Direction /R2L`). +fn right_to_left(doc: &Document) -> bool { + let Some(root) = doc.root() else { return false }; + let Some(prefs) = doc.get(root).as_dict().and_then(|c| c.get(b"ViewerPreferences")).map(|o| doc.resolve(o)) else { return false }; + prefs.as_dict().and_then(|p| p.get(b"Direction")).map(|o| doc.resolve(o)).is_some_and(|d| d.as_name() == Some(b"R2L")) +} + +/// Set the tab order of pages (0-based). +pub fn set_tab_order(doc: &mut Document, pages: &[usize], order: TabOrder) -> Result<(), FormError> { + let refs = page_refs(doc); + for &p in pages { + let r = *refs.get(p).ok_or_else(|| FormError::Invalid(format!("page {} does not exist", p + 1)))?; + doc.update_dict(r, |d| match order { + TabOrder::Row => d.set(b"Tabs".to_vec(), Object::name("R")), + TabOrder::Column => d.set(b"Tabs".to_vec(), Object::name("C")), + TabOrder::Structure => d.set(b"Tabs".to_vec(), Object::name("S")), + TabOrder::Annotations => { + d.remove(b"Tabs"); + } + })?; + } + Ok(()) +} + +/// The tab order of each page, in page order (`/Tabs`; a page without one is unspecified). +pub fn tab_orders(doc: &Document) -> Vec { + page_refs(doc).into_iter().map(|r| TabOrder::of_page(doc, r)).collect() +} + +/// Order tabs manually (Acrobat: Fields ▸ Tab Order ▸ Order Tabs Manually, then drag a field): +/// move field `name` one place earlier or later in its page's tab order. The page switches to +/// annotation order (`/Tabs` removed) and its widgets are reordered in `/Annots`; other +/// annotations keep their places. +pub fn move_in_tab_order(doc: &mut Document, name: &str, earlier: bool) -> Result<(), FormError> { + let all = fields(doc); + let f = all.iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?; + let page = f.widgets.iter().filter_map(|w| w.page).min().ok_or_else(|| FormError::Invalid(format!("{name} is not on a page")))?; + // The page's fields in their current tab order (a field counts once, at its first widget). + let mut order: Vec<(usize, &str)> = + all.iter().filter_map(|g| g.widgets.iter().filter(|w| w.page == Some(page)).map(|w| w.tab).min().map(|t| (t, g.name.as_str()))).collect(); + order.sort(); + let names: Vec<&str> = order.iter().map(|(_, n)| *n).collect(); + let Some(i) = names.iter().position(|n| *n == name) else { return invalid(format!("{name} is not on a page")) }; + let j = if earlier { i.checked_sub(1) } else { Some(i + 1).filter(|j| *j < names.len()) }; + let Some(j) = j else { return Ok(()) }; + let mut names = names.into_iter().map(str::to_string).collect::>(); + names.swap(i, j); + // The widgets of this page in the new order. + let mut widgets: Vec = Vec::new(); + for n in &names { + if let Some(g) = all.iter().find(|g| &g.name == n) { + widgets.extend(g.widgets.iter().filter(|w| w.page == Some(page)).map(|w| w.obj)); + } + } + let pr = *page_refs(doc).get(page).ok_or_else(|| FormError::Invalid("no such page".into()))?; + let annots_obj = + doc.get(pr).as_dict().and_then(|d| d.get(b"Annots").cloned()).ok_or_else(|| FormError::Invalid("the page has no annotations".into()))?; + let list = doc.resolve(&annots_obj).as_array().cloned().unwrap_or_default(); + let mut next = widgets.into_iter(); + let set: std::collections::HashSet = all.iter().flat_map(|g| g.widgets.iter().filter(|w| w.page == Some(page)).map(|w| w.obj)).collect(); + let new_list: Vec = list + .iter() + .map(|o| match o.as_ref() { + Some(r) if set.contains(&r) => next.next().map(Object::Ref).unwrap_or_else(|| o.clone()), + _ => o.clone(), + }) + .collect(); + match annots_obj { + Object::Ref(ar) => doc.set(ar, Object::Array(new_list)), + _ => doc.update_dict(pr, |d| d.set(b"Annots".to_vec(), Object::Array(new_list)))?, + } + doc.update_dict(pr, |d| { + d.remove(b"Tabs"); + })?; + Ok(()) +} diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 61dab131e0..cca4cb4881 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -1711,6 +1711,73 @@ fn the_calculation_order_is_checked_fixed_and_kept() { assert_eq!(order.fixed(), ["sub", "total", "tax", "grand"], "only tax moves, to just after total"); } +/// `set_props` refuses a notation it couldn't store as written before it writes anything: the +/// tooltip given with it, which is written before the calculation, isn't set either. +#[test] +fn a_notation_that_cannot_be_stored_changes_nothing() { + use crate::af::Calculate; + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>".into(), + "<< /Fields [5 0 R] /DA (/Helv 0 Tf 0 g) >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (total) /Rect [50 700 140 711] /P 3 0 R >>".into(), + ]; + let mut doc = document(&objs); + for n in ["", "a EVCALC b", "a */ b", "a\\"] { + let props = FieldProps { tooltip: Some("Total".into()), calculate: Some(Calculate::Notation(n.into())), ..Default::default() }; + assert!(set_props(&mut doc, "total", &props).is_err(), "{n:?}"); + let all = fields(&doc); + let f = field(&all, "total"); + assert_eq!((f.tooltip.as_deref(), &f.actions.calculate), (None, &Calculate::None), "{n:?} changed nothing"); + } +} + +/// A formula that reads a field the form doesn't have is flagged, whichever kind of formula it +/// is: a field deleted from under a sum, a typo in notation, a script's `getField`. So is a +/// script that builds the names it reads. Neither is an order Fix order can fix. +#[test] +fn missing_fields_and_built_names_are_flagged() { + use crate::af::{CalcOp, Calculate}; + use crate::calc_order::CalculationIssue::{BuiltNames, MissingField}; + let mut doc = one_page(); + let text = NewField::Text { multiline: false }; + for (i, name) in ["qty", "gone", "sum", "notation", "script", "built"].into_iter().enumerate() { + let y = 700.0 - 40.0 * i as f64; + add_field(&mut doc, 0, [50.0, y, 250.0, y + 20.0], &text, Some(name)).unwrap(); + } + let calculate = |c: Calculate| FieldProps { calculate: Some(c), ..FieldProps::default() }; + set_props(&mut doc, "sum", &calculate(Calculate::Simple { op: CalcOp::Sum, fields: vec!["qty".into(), "gone".into()] })).unwrap(); + set_props(&mut doc, "notation", &calculate(Calculate::Notation("qty * nope".into()))).unwrap(); + // qty has one widget, but a widget name is no field: recalculation and getField give qty.0 + // nothing, so it is as missing as qty.1. + let script = "event.value = getField(\"qty\").value + getField(\"ghost\").value + getField(\"qty.0\").value + getField(\"qty.1\").value;"; + set_field_script(&mut doc, "script", "calculate", Some(script)).unwrap(); + set_field_script(&mut doc, "built", "calculate", Some("event.value = getField(\"qty\").value + getField(\"row\" + 1).value;")).unwrap(); + let issues = |doc: &Document| { + let order = calculation_order(doc, &fields(doc)); + assert!(!order.fixable(), "nothing Fix order can fix"); + order.fields.into_iter().map(|f| (f.name, f.issues)).collect::>() + }; + let expected = |sum: Vec<_>| { + vec![ + ("sum".to_owned(), sum), + ("notation".to_owned(), vec![MissingField { field: "nope".into() }]), + ( + "script".to_owned(), + vec![MissingField { field: "ghost".into() }, MissingField { field: "qty.0".into() }, MissingField { field: "qty.1".into() }], + ), + ("built".to_owned(), vec![BuiltNames]), + ] + }; + assert_eq!(issues(&doc), expected(vec![])); + delete_field(&mut doc, "gone").unwrap(); + assert_eq!(issues(&doc), expected(vec![MissingField { field: "gone".into() }]), "a deleted field leaves the sum reading nothing"); + assert_eq!(issues(&reopen(&doc)), expected(vec![MissingField { field: "gone".into() }])); + let order = calculation_order(&doc, &fields(&doc)); + assert_eq!(order.fields[3].reads, ["qty"], "the names it does know are still read"); +} + /// `/CO` as other tools leave it: a calculated field missing, a field without a calculation, an /// entry repeated, a reference to nothing and a number. The order reads around them, and setting /// the order writes `/CO` afresh. @@ -1775,6 +1842,12 @@ fn two_fields_with_one_name_keep_their_places_in_the_order() { }; let row = |name: &str, reads: &str, ok: bool| (name.to_owned(), vec![reads.to_owned()], ok); assert_eq!(rows(&doc), [row("dup", "src", false), row("src", "x", true), row("dup", "x", true)]); + // Each row is its own field object, so a caller can find the right formula for each dup. + let all = fields(&doc); + for c in calculation_order(&doc, &all).fields { + let f = all.iter().find(|f| f.obj == c.obj).unwrap(); + assert_eq!((&f.name, f.actions.calculate.reads()), (&c.name, c.reads.clone())); + } let current: Vec = ["dup", "src", "dup"].map(String::from).into(); set_calculation_order(&mut doc, ¤t).unwrap(); assert_eq!(rows(&doc), [row("dup", "src", false), row("src", "x", true), row("dup", "x", true)], "the current order sets back unchanged"); @@ -1796,7 +1869,7 @@ fn two_fields_with_one_name_keep_their_places_in_the_order() { #[test] fn new_text_fields_fit_their_box() { // #1888: a fixed 12 pt was clipped in a short box. New text and date fields take Acrobat's - // Auto size: 12 pt where there is room, smaller where there isn't. + // Auto size: the size the box's height gives, smaller where the text is too wide. let mut doc = one_page(); let tf_size = |ap: &str| { ap.split("/Helv ") @@ -1815,7 +1888,9 @@ fn new_text_fields_fit_their_box() { } add_field(&mut doc, 0, [20.0, 50.0, 200.0, 72.0], &NewField::Text { multiline: false }, Some("tall")).unwrap(); set_value(&mut doc, "tall", &FieldValue::Text("Hello world".into())).unwrap(); - assert_eq!(tf_size(&ap(&doc, &field(&fields(&doc), "tall").widgets[0])), 12.0, "a box with room keeps the 12 pt look"); + // 22 pt tall, less a 1 pt border and 2 pt padding each side: 16 / 1.15. + let tall = tf_size(&ap(&doc, &field(&fields(&doc), "tall").widgets[0])); + assert!((tall - 16.0 / 1.15).abs() < 0.01, "a box with room fills its height, not 12 pt: {tall}"); // An explicit size still wins. set_props(&mut doc, "short", &FieldProps { font_size: Some(12.0), ..FieldProps::default() }).unwrap(); assert_eq!(tf_size(&ap(&doc, &field(&fields(&doc), "short").widgets[0])), 12.0); @@ -1875,6 +1950,73 @@ fn tab_order_follows_the_page_setting() { assert!(set_tab_order(&mut doc.clone(), &[3], TabOrder::Row).is_err()); } +#[test] +fn rows_and_columns_tab_right_to_left_in_a_document_bound_on_the_right() { + let mut doc = one_page(); + let text = NewField::Text { multiline: false }; + // A (50,700) B (300,700) + // C (50,600) D (300,600) + for (name, x, y) in [("C", 50.0, 600.0), ("A", 50.0, 700.0), ("D", 300.0, 600.0), ("B", 300.0, 700.0)] { + add_field(&mut doc, 0, [x, y, x + 200.0, y + 20.0], &text, Some(name)).unwrap(); + } + let order = |doc: &Document| -> String { + let mut all: Vec<(usize, String)> = fields(doc).into_iter().map(|f| (f.widgets[0].tab, f.name)).collect(); + all.sort(); + all.into_iter().map(|x| x.1).collect() + }; + let root = doc.root().unwrap(); + let mut prefs = pdfcraft_cos::Dict::new(); + prefs.set(b"Direction".to_vec(), Object::name("R2L")); + let prefs = doc.add(prefs); + doc.update_dict(root, |c| c.set(b"ViewerPreferences".to_vec(), Object::Ref(prefs))).unwrap(); + set_tab_order(&mut doc, &[0], TabOrder::Row).unwrap(); + assert_eq!(order(&doc), "BADC", "rows top to bottom, each right to left"); + set_tab_order(&mut doc, &[0], TabOrder::Column).unwrap(); + assert_eq!(order(&doc), "BDAC", "columns right to left, each top to bottom"); + assert_eq!(order(&reopen(&doc)), "BDAC"); + set_tab_order(&mut doc, &[0], TabOrder::Annotations).unwrap(); + assert_eq!(order(&doc), "CADB", "unspecified keeps annotation order"); + doc.update_dict(prefs, |p| p.set(b"Direction".to_vec(), Object::name("L2R"))).unwrap(); + set_tab_order(&mut doc, &[0], TabOrder::Row).unwrap(); + assert_eq!(order(&doc), "ABCD", "bound on the left"); +} + +#[test] +fn tab_orders_are_read_for_each_page() { + let mut doc = document(&[ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R 4 0 R 5 0 R 6 0 R 7 0 R] /Count 5 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Tabs /C >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + "<< /Type /Page /Parent 2 0 R /Tabs /S >>".into(), + "<< /Type /Page /Parent 2 0 R /Tabs /W >>".into(), + "<< /Type /Page /Parent 2 0 R /Tabs 8 0 R >>".into(), + "/R".into(), + ]); + use TabOrder::{Annotations, Column, Row, Structure}; + assert_eq!(tab_orders(&doc), [Column, Annotations, Structure, Annotations, Row], "no /Tabs, or an unknown name: unspecified"); + set_tab_order(&mut doc, &[1], Row).unwrap(); + set_tab_order(&mut doc, &[0, 4], Annotations).unwrap(); + let doc = reopen(&doc); + assert_eq!(tab_orders(&doc), [Annotations, Row, Structure, Annotations, Annotations]); +} + +#[test] +fn an_indirect_tabs_value_orders_the_page() { + let mut doc = one_page(); + let text = NewField::Text { multiline: false }; + for (name, x, y) in [("C", 50.0, 600.0), ("A", 50.0, 700.0), ("D", 300.0, 600.0), ("B", 300.0, 700.0)] { + add_field(&mut doc, 0, [x, y, x + 200.0, y + 20.0], &text, Some(name)).unwrap(); + } + let column = doc.add(Object::name("C")); + let page = page_refs(&doc)[0]; + doc.update_dict(page, |p| p.set(b"Tabs".to_vec(), Object::Ref(column))).unwrap(); + let mut all: Vec<(usize, String)> = fields(&doc).into_iter().map(|f| (f.widgets[0].tab, f.name)).collect(); + all.sort(); + assert_eq!(all.into_iter().map(|x| x.1).collect::(), "ACBD", "columns, as a direct /C orders them"); + assert_eq!(tab_orders(&doc), [TabOrder::Column]); +} + #[test] fn options_tab_flags_alignment_and_defaults() { let mut doc = one_page(); @@ -3158,8 +3300,12 @@ fn auto_text_size_fits_single_and_multiline_text() { assert_eq!(auto_text_size("Hi", 94.0, 8.0, false, helvetica_width), 8.0 / 1.15); let s = auto_text_size(long, 194.0, 8.0, false, helvetica_width); assert!(s > 4.0 && s < 8.0 / 1.15 && helvetica_width(long, s) <= 194.0 + 1e-9, "{s}"); - // Never past 12 pt or under 4 pt. - assert_eq!(auto_text_size("Hi", 500.0, 500.0, false, helvetica_width), 12.0); + // A tall single-line box gets text as tall as it allows (Acrobat has no 12 pt cap there), + // bounded by the largest page; never under 4 pt. + assert_eq!(auto_text_size("Hi", 500.0, 24.0, false, helvetica_width), 24.0 / 1.15); + assert_eq!(auto_text_size("", 500.0, 1e300, false, helvetica_width), crate::appearance::MAX_AUTO_HEIGHT / 1.15); + let s = auto_text_size("Hi", 500.0, 500.0, false, helvetica_width); + assert!(s > 12.0 && helvetica_width("Hi", s) <= 500.0 + 1e-9, "{s}"); assert_eq!(auto_text_size(&long.repeat(20), 20.0, 8.0, false, helvetica_width), 4.0); // Multiline: the largest size whose wrapped lines fit the height. let m = auto_text_size(long, 114.0, 44.0, true, helvetica_width); @@ -3601,13 +3747,17 @@ fn dotted_field_names_are_made_with_parent_fields() { }) .unwrap(); + invalid_dotted_field_names_are_rejected(&mut doc, &text); +} + +fn invalid_dotted_field_names_are_rejected(doc: &mut Document, text: &NewField) { for bad in ["MASTER", "MASTER.street1.x", "MASTER..x", ".x", "x.", "MASTER. "] { - assert!(add_field(&mut doc, 0, [300.0, 200.0, 450.0, 220.0], &text, Some(bad)).is_err(), "{bad:?}"); - assert!(set_props(&mut doc, "name", &FieldProps { name: Some(bad.into()), ..FieldProps::default() }).is_err(), "{bad:?}"); + assert!(add_field(doc, 0, [300.0, 200.0, 450.0, 220.0], text, Some(bad)).is_err(), "{bad:?}"); + assert!(set_props(doc, "name", &FieldProps { name: Some(bad.into()), ..FieldProps::default() }).is_err(), "{bad:?}"); } let deep = vec!["p"; 33].join("."); - assert!(add_field(&mut doc, 0, [300.0, 200.0, 450.0, 220.0], &text, Some(&deep)).is_err(), "too deep"); - assert!(set_props(&mut doc, "name", &FieldProps { name: Some("MASTER.street1".into()), ..FieldProps::default() }).is_err(), "taken"); + assert!(add_field(doc, 0, [300.0, 200.0, 450.0, 220.0], text, Some(&deep)).is_err(), "too deep"); + assert!(set_props(doc, "name", &FieldProps { name: Some("MASTER.street1".into()), ..FieldProps::default() }).is_err(), "taken"); } /// Review on #2957: a parent left empty by a move keeps its name from the next default name @@ -3801,90 +3951,17 @@ fn existing_form_fonts_are_kept_and_an_unchanged_resource_dictionary_is_not_rewr /// - Helv, TiRo and Cour are there. #[test] fn every_shape_of_form_fonts_ends_as_objects() { - let font = |base: &str| format!("<< /Type /Font /Subtype /Type1 /BaseFont /{base} >>"); let mut cases = 0; for dr_shape in ["missing", "inline", "object"] { for font_shape in ["missing", "inline", "object", "number"] { for helv in ["missing", "inline", "object", "dangling", "null", "number"] { for mine in ["missing", "inline", "object"] { - if dr_shape == "missing" && (font_shape != "missing" || helv != "missing" || mine != "missing") { - continue; - } - if matches!(font_shape, "missing" | "number") && (helv != "missing" || mine != "missing") { + let case = FontShapeCase::new(dr_shape, font_shape, helv, mine); + if !case.is_valid() { continue; } cases += 1; - let what = format!("/DR {dr_shape}, /Font {font_shape}, /Helv {helv}, /Mine {mine}"); - let mut entries = String::new(); - match helv { - "inline" => entries += &format!("/Helv {} ", font("Arial")), - "object" => entries += "/Helv 7 0 R ", - "dangling" => entries += "/Helv 99 0 R ", - "null" => entries += "/Helv null ", - "number" => entries += "/Helv 3 ", - _ => {} - } - match mine { - "inline" => entries += &format!("/Mine {} ", font("Symbol")), - "object" => entries += "/Mine 8 0 R ", - _ => {} - } - let fonts_value = match font_shape { - "inline" => format!("<< {entries}>>"), - "object" => "6 0 R".into(), - "number" => "7".into(), - _ => String::new(), - }; - let dr_dict = if fonts_value.is_empty() { "<< >>".to_string() } else { format!("<< /Font {fonts_value} >>") }; - let dr_value = match dr_shape { - "inline" => dr_dict.clone(), - "object" => "5 0 R".into(), - _ => String::new(), - }; - let af = if dr_value.is_empty() { "<< /Fields [] >>".to_string() } else { format!("<< /Fields [] /DR {dr_value} >>") }; - let mut doc = document(&[ - "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), - "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), - "<< /Type /Page /Parent 2 0 R >>".into(), - af, - if dr_shape == "object" { dr_dict } else { "null".into() }, - if font_shape == "object" { format!("<< {entries}>>") } else { "null".into() }, - font("Helvetica-Oblique"), - font("ZapfDingbats"), - ]); - add_field(&mut doc, 0, [40.0, 600.0, 300.0, 630.0], &NewField::Text { multiline: false }, Some("name")) - .unwrap_or_else(|e| panic!("{what}: {e}")); - let doc = reopen(&doc); - let af = acroform(&doc).unwrap(); - let dr = doc.resolve(af.get(b"DR").unwrap_or_else(|| panic!("{what}: no /DR"))); - let fonts = dr_fonts(&doc); - for (name, value) in fonts.iter() { - assert!(value.as_dict().is_none(), "{what}: {} is inline", String::from_utf8_lossy(name)); - } - for name in ["Helv", "TiRo", "Cour"] { - assert!(fonts.contains(name.as_bytes()), "{what}: no {name}"); - } - let base = - |name: &[u8]| fonts.reference(name).and_then(|r| doc.get(r).as_dict().and_then(|f| f.name(b"BaseFont").map(<[u8]>::to_vec))); - match helv { - "inline" => assert_eq!(base(b"Helv").as_deref(), Some(&b"Arial"[..]), "{what}"), - "object" => assert_eq!(fonts.reference(b"Helv"), Some(pdfcraft_cos::ObjRef::new(7, 0)), "{what}"), - "dangling" => assert_eq!(fonts.reference(b"Helv"), Some(pdfcraft_cos::ObjRef::new(99, 0)), "{what}"), - "null" => assert_eq!(fonts.get(b"Helv"), Some(&Object::Null), "{what}"), - "number" => assert_eq!(fonts.get(b"Helv"), Some(&Object::Int(3)), "{what}"), - _ => assert_eq!(base(b"Helv").as_deref(), Some(&b"Helvetica"[..]), "{what}"), - } - match mine { - "inline" => assert_eq!(base(b"Mine").as_deref(), Some(&b"Symbol"[..]), "{what}"), - "object" => assert_eq!(fonts.reference(b"Mine"), Some(pdfcraft_cos::ObjRef::new(8, 0)), "{what}"), - _ => assert!(!fonts.contains(b"Mine"), "{what}"), - } - if dr_shape == "object" { - assert_eq!(af.reference(b"DR"), Some(pdfcraft_cos::ObjRef::new(5, 0)), "{what}: /DR stays its object"); - } - if font_shape == "object" { - assert_eq!(dr.as_dict().unwrap().reference(b"Font"), Some(pdfcraft_cos::ObjRef::new(6, 0)), "{what}: /Font stays its object"); - } + assert_font_shape_case(&case); } } } @@ -3892,6 +3969,118 @@ fn every_shape_of_form_fonts_ends_as_objects() { assert_eq!(cases, 1 + 2 * (2 + 2 * 6 * 3), "every shape was tried"); } +struct FontShapeCase<'a> { + dr: &'a str, + fonts: &'a str, + helv: &'a str, + mine: &'a str, + description: String, +} + +impl<'a> FontShapeCase<'a> { + fn new(dr: &'a str, fonts: &'a str, helv: &'a str, mine: &'a str) -> Self { + Self { dr, fonts, helv, mine, description: format!("/DR {dr}, /Font {fonts}, /Helv {helv}, /Mine {mine}") } + } + + fn is_valid(&self) -> bool { + let only_default_dr = self.dr == "missing" && self.fonts == "missing" && self.helv == "missing" && self.mine == "missing"; + let invalid_fonts = matches!(self.fonts, "missing" | "number") && (self.helv != "missing" || self.mine != "missing"); + !(self.dr == "missing" && !only_default_dr || invalid_fonts) + } +} + +fn assert_font_shape_case(case: &FontShapeCase<'_>) { + let mut doc = font_shape_document(case); + add_field(&mut doc, 0, [40.0, 600.0, 300.0, 630.0], &NewField::Text { multiline: false }, Some("name")) + .unwrap_or_else(|e| panic!("{}: {e}", case.description)); + let doc = reopen(&doc); + let acro = acroform(&doc).unwrap(); + let dr = doc.resolve(acro.get(b"DR").unwrap_or_else(|| panic!("{}: no /DR", case.description))); + let fonts = dr_fonts(&doc); + assert_font_entries_are_references(&fonts, &case.description); + assert_required_form_fonts(&fonts, &case.description); + assert_requested_font_shapes(&doc, &acro, &dr, &fonts, case); +} + +fn font_shape_document(case: &FontShapeCase<'_>) -> Document { + let mut entries = String::new(); + match case.helv { + "inline" => entries += &format!("/Helv {} ", form_font("Arial")), + "object" => entries += "/Helv 7 0 R ", + "dangling" => entries += "/Helv 99 0 R ", + "null" => entries += "/Helv null ", + "number" => entries += "/Helv 3 ", + _ => {} + } + match case.mine { + "inline" => entries += &format!("/Mine {} ", form_font("Symbol")), + "object" => entries += "/Mine 8 0 R ", + _ => {} + } + let fonts_value = match case.fonts { + "inline" => format!("<< {entries}>>"), + "object" => "6 0 R".into(), + "number" => "7".into(), + _ => String::new(), + }; + let dr_dict = if fonts_value.is_empty() { "<< >>".to_string() } else { format!("<< /Font {fonts_value} >>") }; + let dr_value = match case.dr { + "inline" => dr_dict.clone(), + "object" => "5 0 R".into(), + _ => String::new(), + }; + let acroform = if dr_value.is_empty() { "<< /Fields [] >>".to_string() } else { format!("<< /Fields [] /DR {dr_value} >>") }; + document(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + acroform, + if case.dr == "object" { dr_dict } else { "null".into() }, + if case.fonts == "object" { format!("<< {entries}>>") } else { "null".into() }, + form_font("Helvetica-Oblique"), + form_font("ZapfDingbats"), + ]) +} + +fn form_font(base: &str) -> String { + format!("<< /Type /Font /Subtype /Type1 /BaseFont /{base} >>") +} + +fn assert_font_entries_are_references(fonts: &Dict, description: &str) { + for (name, value) in fonts.iter() { + assert!(value.as_dict().is_none(), "{description}: {} is inline", String::from_utf8_lossy(name)); + } +} + +fn assert_required_form_fonts(fonts: &Dict, description: &str) { + for name in ["Helv", "TiRo", "Cour"] { + assert!(fonts.contains(name.as_bytes()), "{description}: no {name}"); + } +} + +fn assert_requested_font_shapes(doc: &Document, acro: &Dict, dr: &Object, fonts: &Dict, case: &FontShapeCase<'_>) { + let base = |name: &[u8]| fonts.reference(name).and_then(|r| doc.get(r).as_dict().and_then(|f| f.name(b"BaseFont").map(<[u8]>::to_vec))); + match case.helv { + "inline" => assert_eq!(base(b"Helv").as_deref(), Some(&b"Arial"[..]), "{}", case.description), + "object" => assert_eq!(fonts.reference(b"Helv"), Some(pdfcraft_cos::ObjRef::new(7, 0)), "{}", case.description), + "dangling" => assert_eq!(fonts.reference(b"Helv"), Some(pdfcraft_cos::ObjRef::new(99, 0)), "{}", case.description), + "null" => assert_eq!(fonts.get(b"Helv"), Some(&Object::Null), "{}", case.description), + "number" => assert_eq!(fonts.get(b"Helv"), Some(&Object::Int(3)), "{}", case.description), + _ => assert_eq!(base(b"Helv").as_deref(), Some(&b"Helvetica"[..]), "{}", case.description), + } + match case.mine { + "inline" => assert_eq!(base(b"Mine").as_deref(), Some(&b"Symbol"[..]), "{}", case.description), + "object" => assert_eq!(fonts.reference(b"Mine"), Some(pdfcraft_cos::ObjRef::new(8, 0)), "{}", case.description), + _ => assert!(!fonts.contains(b"Mine"), "{}", case.description), + } + if case.dr == "object" { + assert_eq!(acro.reference(b"DR"), Some(pdfcraft_cos::ObjRef::new(5, 0)), "{}: /DR stays its object", case.description); + } + if case.fonts == "object" { + assert_eq!(dr.as_dict().unwrap().reference(b"Font"), Some(pdfcraft_cos::ObjRef::new(6, 0)), "{}: /Font stays its object", case.description); + } +} + /// #3441 (review follow-up): filling a field converts the form's inline `/DR` fonts, so a form /// an earlier PdfCraft made works in Acrobat once it has been filled here. Nothing is added. A /// form a signature covers keeps `/DR` as the signer saw it, and a form whose fonts are objects @@ -4577,3 +4766,722 @@ fn set_values_reads_the_form_once_not_per_value() { let reads = form_reads() - before; assert_eq!(reads, 3, "one to fill, one when f0 comes again, one to recalculate"); } + +/// A radio group `size` with the buttons Small (on), Medium and Large, a per-widget `/Opt`, and +/// the group's buttons in the calculation order; and a text field `name` with two widgets. +fn radio_group_form() -> Document { + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), // 1 + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), // 2 + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R 8 0 R 10 0 R 11 0 R] >>".into(), // 3 + "<< /Fields [5 0 R 9 0 R] /DA (/Helv 0 Tf 0 g) /CO [7 0 R] >>".into(), // 4 + "<< /FT /Btn /Ff 49152 /T (size) /V /S /Opt [(small) (medium) (large)] /Kids [6 0 R 7 0 R 8 0 R] >>".into(), // 5 + "<< /Type /Annot /Subtype /Widget /Parent 5 0 R /Rect [50 700 64 714] /P 3 0 R /AS /S /AP << /N << /S 12 0 R /Off 12 0 R >> >> >>".into(), // 6 + "<< /Type /Annot /Subtype /Widget /Parent 5 0 R /Rect [80 700 94 714] /P 3 0 R /AS /Off /AP << /N << /M 12 0 R /Off 12 0 R >> >> >>".into(), // 7 + "<< /Type /Annot /Subtype /Widget /Parent 5 0 R /Rect [110 700 124 714] /P 3 0 R /AS /Off /AP << /N << /L 12 0 R /Off 12 0 R >> >> >>".into(), // 8 + "<< /FT /Tx /T (name) /V (Ann) /Kids [10 0 R 11 0 R] >>".into(), // 9 + "<< /Type /Annot /Subtype /Widget /Parent 9 0 R /Rect [50 600 250 620] /P 3 0 R >>".into(), // 10 + "<< /Type /Annot /Subtype /Widget /Parent 9 0 R /Rect [300 600 500 620] /P 3 0 R >>".into(), // 11 + "<< /Length 0 >>\nstream\n\nendstream".into(), // 12 + ]; + document(&objs) +} + +fn annots(doc: &Document) -> Vec { + let p = page_refs(doc)[0]; + let a = doc.resolve(doc.get(p).as_dict().unwrap().get(b"Annots").unwrap()); + a.as_array().unwrap().iter().map(|o| o.as_ref().unwrap().num).collect() +} + +#[test] +fn deleting_one_radio_button_keeps_the_group_and_its_other_buttons() { + let mut doc = radio_group_form(); + delete_widget(&mut doc, "size", 1).unwrap(); + let doc = reopen(&doc); + let all = fields(&doc); + let size = field(&all, "size"); + assert_eq!(size.widgets.iter().map(|w| w.obj.num).collect::>(), [6, 8], "only Medium is gone"); + assert_eq!(size.value, ["S"], "the group keeps its choice"); + // The per-widget /Opt loses Medium's entry, so the others keep their export values. + assert_eq!((size.export_of(0), size.export_of(1)), (Some("small"), Some("large"))); + assert_eq!(annots(&doc), [6, 8, 10, 11], "the button left the page"); + let co = doc.resolve(doc.get(ObjRef::new(4, 0)).as_dict().unwrap().get(b"CO").unwrap()).as_array().cloned().unwrap(); + assert!(co.is_empty(), "and the calculation order"); + assert!(field(&all, "name").widgets.len() == 2, "other fields are untouched"); +} + +#[test] +fn deleting_the_chosen_radio_button_turns_the_group_off() { + let mut doc = radio_group_form(); + delete_widget(&mut doc, "size", 0).unwrap(); + let all = fields(&reopen(&doc)); + let size = field(&all, "size"); + assert_eq!(size.widgets.iter().map(|w| w.obj.num).collect::>(), [7, 8]); + assert!(size.value.is_empty(), "no button is on any more: {:?}", size.value); + assert_eq!((size.export_of(0), size.export_of(1)), (Some("medium"), Some("large"))); +} + +#[test] +fn deleting_the_chosen_radio_button_turns_the_group_off_when_a_twin_is_not_shown_on() { + // Medium shares Small's on-state name but shows Off: once Small is gone, no button shows + // the group's /V, so the group must not keep reporting it. + let mut doc = radio_group_form(); + let twin = ObjRef::new(7, 0); + let blank = Object::Ref(ObjRef::new(12, 0)); + let mut n = Dict::new(); + n.set(b"S".to_vec(), blank.clone()); + n.set(b"Off".to_vec(), blank); + let mut ap = Dict::new(); + ap.set(b"N".to_vec(), Object::Dict(n)); + let mut w = doc.get(twin).as_dict().cloned().unwrap(); + w.set(b"AP".to_vec(), Object::Dict(ap)); + doc.set(twin, Object::Dict(w)); + delete_widget(&mut doc, "size", 0).unwrap(); + let all = fields(&reopen(&doc)); + let size = field(&all, "size"); + assert_eq!(size.widgets.iter().map(|w| w.obj.num).collect::>(), [7, 8]); + assert!(size.value.is_empty(), "no button shows S any more: {:?}", size.value); +} + +#[test] +fn deleting_the_default_radio_button_clears_the_default_too() { + // The group is off, but Reset would put back Small, which no button has once it is gone. + let mut doc = radio_group_form(); + let size = ObjRef::new(5, 0); + let mut d = doc.get(size).as_dict().cloned().unwrap(); + d.set(b"V".to_vec(), Object::name("Off")); + d.set(b"DV".to_vec(), Object::name("S")); + doc.set(size, Object::Dict(d)); + delete_widget(&mut doc, "size", 0).unwrap(); + let doc = reopen(&doc); + let dv = doc.get(size).as_dict().and_then(|d| d.get(b"DV").cloned()); + assert_eq!(dv, Some(Object::name("Off"))); + // A default another button still has stays. + let mut doc = radio_group_form(); + let mut d = doc.get(size).as_dict().cloned().unwrap(); + d.set(b"DV".to_vec(), Object::name("S")); + doc.set(size, Object::Dict(d)); + delete_widget(&mut doc, "size", 1).unwrap(); + assert_eq!(doc.get(size).as_dict().and_then(|d| d.get(b"DV").cloned()), Some(Object::name("S"))); +} + +#[test] +fn deleting_one_copy_of_a_field_keeps_the_field_and_its_value() { + let mut doc = radio_group_form(); + delete_widget(&mut doc, "name", 0).unwrap(); + let doc = reopen(&doc); + let all = fields(&doc); + let name = field(&all, "name"); + assert_eq!((name.widgets.iter().map(|w| w.obj.num).collect::>(), name.value.clone()), (vec![11], vec!["Ann".to_string()])); + assert_eq!(annots(&doc), [6, 7, 8, 11]); + // The last widget takes the field with it, as deleting the field does. + let mut doc = doc; + delete_widget(&mut doc, "name", 0).unwrap(); + let doc = reopen(&doc); + assert!(!fields(&doc).iter().any(|f| f.name == "name")); + assert_eq!(annots(&doc), [6, 7, 8]); +} + +#[test] +fn deleting_a_radio_button_without_its_parent_link_takes_it_out_of_the_group() { + // A damaged file: the group lists Medium in its /Kids, but Medium has no /Parent. + for parent in [None, Some(Object::Ref(ObjRef::new(9, 0)))] { + let mut doc = radio_group_form(); + let medium = ObjRef::new(7, 0); + let mut w = doc.get(medium).as_dict().cloned().unwrap(); + w.remove(b"Parent"); + if let Some(p) = parent.clone() { + w.set(b"Parent".to_vec(), p); + } + doc.set(medium, Object::Dict(w)); + delete_widget(&mut doc, "size", 1).unwrap(); + let doc = reopen(&doc); + let all = fields(&doc); + assert_eq!(field(&all, "size").widgets.iter().map(|w| w.obj.num).collect::>(), [6, 8], "/Parent {parent:?}"); + let kids = doc.resolve(doc.get(ObjRef::new(5, 0)).as_dict().unwrap().get(b"Kids").unwrap()).as_array().cloned().unwrap(); + assert!(!kids.iter().any(|k| k.as_ref() == Some(medium)), "/Parent {parent:?}: {kids:?}"); + assert_eq!(field(&all, "name").widgets.len(), 2, "/Parent {parent:?}: the other field is untouched"); + assert_eq!(annots(&doc), [6, 8, 10, 11]); + } +} + +#[test] +fn deleting_a_widget_that_is_not_there_changes_nothing() { + let mut doc = radio_group_form(); + assert_eq!(delete_widget(&mut doc, "size", 3), Err(FormError::Invalid("size has no widget 4".into()))); + assert_eq!(delete_widget(&mut doc, "nope", 0), Err(FormError::NoSuchField("nope".into()))); + assert_eq!(delete_widget(&mut doc, "size", usize::MAX), Err(FormError::Invalid(format!("size has no widget {}", usize::MAX)))); + assert_eq!(field(&fields(&doc), "size").widgets.len(), 3); +} +/// Two new radio groups on one page, "Group1" (export `a`) and "Group2" (export `b`). +fn two_radio_groups(a: &str, b: &str) -> Document { + let mut doc = one_page(); + let radio = |export: &str| NewField::Radio { group: None, export: export.into() }; + assert_eq!(add_field(&mut doc, 0, [50.0, 600.0, 64.0, 614.0], &radio(a), None).unwrap(), "Group1"); + assert_eq!(add_field(&mut doc, 0, [80.0, 600.0, 94.0, 614.0], &radio(b), None).unwrap(), "Group2"); + doc +} + +fn rename_to(doc: &mut Document, from: &str, to: &str) -> Result { + set_props(doc, from, &FieldProps { name: Some(to.into()), ..FieldProps::default() }) +} + +fn page_widgets(doc: &Document) -> Vec { + let page = page_refs(doc)[0]; + let annots = doc.get(page).as_dict().unwrap().get(b"Annots").cloned().unwrap(); + doc.resolve(&annots).as_array().unwrap().iter().filter_map(|o| o.as_ref()).collect() +} + +/// #4949: a radio button given its group's name is one of the group's buttons, so the group can +/// be built up by naming buttons alike, as in Acrobat. +#[test] +fn a_radio_button_renamed_to_a_groups_name_joins_the_group() { + let mut doc = two_radio_groups("Yes", "No"); + set_value(&mut doc, "Group1", &FieldValue::Radio(Some("Yes".into()))).unwrap(); + let before = page_widgets(&doc); + assert_eq!(rename_to(&mut doc, "Group2", "Group1").unwrap(), "Group1"); + assert_eq!(page_widgets(&doc), before, "the buttons stay on the page where they were"); + let mut doc = reopen(&doc); + let all = fields(&doc); + assert_eq!(all.iter().map(|f| f.name.as_str()).collect::>(), ["Group1"]); + let group = field(&all, "Group1"); + assert_eq!(group.widgets.iter().filter_map(|w| w.on_state.clone()).collect::>(), ["Yes", "No"]); + assert_eq!(group.value, ["Yes"], "the group keeps its value"); + assert_eq!(group.widgets.iter().map(|w| w.state.clone()).collect::>(), [Some("Yes".into()), Some("Off".into())]); + assert_eq!(page_widgets(&doc), group.widgets.iter().map(|w| w.obj).collect::>()); + // Choosing the moved button turns the other off: they are one group. + set_value(&mut doc, "Group1", &FieldValue::Radio(Some("No".into()))).unwrap(); + let group = field(&fields(&doc), "Group1").clone(); + assert_eq!(group.widgets.iter().map(|w| w.state.clone()).collect::>(), [Some("Off".into()), Some("No".into())]); +} + +/// Joining redraws only the buttons that moved: the group's own keep their appearances, which may +/// be custom ones a redraw would replace. +#[test] +fn joining_a_group_leaves_its_own_buttons_appearances_as_they_are() { + let mut doc = two_radio_groups("Yes", "No"); + let own = field(&fields(&doc), "Group1").widgets[0].obj; + let look = |doc: &Document| doc.get(own).as_dict().and_then(|d| d.get(b"AP").cloned()); + let before = look(&doc); + assert!(before.is_some()); + rename_to(&mut doc, "Group2", "Group1").unwrap(); + assert_eq!(look(&doc), before); + let group = field(&fields(&doc), "Group1").clone(); + assert!(group.widgets[1].on_state.as_deref() == Some("No"), "the moved button is drawn with its states"); +} + +/// A joining button whose export value a button of the group already has takes the next free +/// "ChoiceN", in its appearance states too, so the two can be chosen apart. +#[test] +fn a_joining_radio_button_with_a_taken_export_value_takes_a_free_one() { + let mut doc = two_radio_groups("Choice1", "Choice1"); + rename_to(&mut doc, "Group2", "Group1").unwrap(); + let doc = reopen(&doc); + let all = fields(&doc); + let group = field(&all, "Group1"); + assert_eq!(group.widgets.iter().filter_map(|w| w.on_state.clone()).collect::>(), ["Choice1", "Choice2"]); + let moved = doc.get(group.widgets[1].obj).as_dict().unwrap().clone(); + let normal = doc.resolve(moved.get(b"AP").unwrap()).as_dict().unwrap().get(b"N").cloned().unwrap(); + let normal = doc.resolve(&normal).as_dict().unwrap().clone(); + assert!(normal.contains(b"Choice2") && !normal.contains(b"Choice1")); +} + +/// A group whose `/Kids` is an indirect array keeps its own buttons when another joins it. +#[test] +fn a_group_with_indirect_kids_keeps_its_buttons_when_joined() { + let mut doc = two_radio_groups("Yes", "No"); + let group = field(&fields(&doc), "Group1").obj; + let kids = doc.get(group).as_dict().unwrap().get(b"Kids").cloned().unwrap(); + let held = doc.add(kids); + doc.update_dict(group, |d| d.set(b"Kids".to_vec(), Object::Ref(held))).unwrap(); + rename_to(&mut doc, "Group2", "Group1").unwrap(); + let doc = reopen(&doc); + let all = fields(&doc); + assert_eq!(field(&all, "Group1").widgets.iter().filter_map(|w| w.on_state.clone()).collect::>(), ["Yes", "No"]); +} + +/// Buttons of the joining group that share an export value (a button repeated on another page, +/// on together) still share it after the join, under one fresh name. +#[test] +fn joining_radio_buttons_that_share_a_value_keep_sharing_it() { + let mut doc = two_radio_groups("Choice1", "Choice1"); + let pages = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Pages").unwrap(); + let mut p = pdfcraft_cos::Dict::new(); + p.set(b"Type".to_vec(), Object::name("Page")); + p.set(b"Parent".to_vec(), Object::Ref(pages)); + p.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 600.into(), 800.into()])); + let r = doc.add(Object::Dict(p)); + doc.update_dict(pages, |d| { + let mut kids = d.get(b"Kids").unwrap().as_array().unwrap().clone(); + kids.push(Object::Ref(r)); + d.set(b"Count".to_vec(), Object::Int(kids.len() as i64)); + d.set(b"Kids".to_vec(), Object::Array(kids)); + }) + .unwrap(); + assert_eq!(duplicate_field(&mut doc, "Group2", &[1]).unwrap(), 1); + rename_to(&mut doc, "Group2", "Group1").unwrap(); + let doc = reopen(&doc); + let all = fields(&doc); + let group = field(&all, "Group1"); + assert_eq!(group.widgets.iter().filter_map(|w| w.on_state.clone()).collect::>(), ["Choice1", "Choice2", "Choice2"]); +} + +/// One page holding radio fields that are each their own only widget, as other writers make +/// one-button groups: `(name, export value, x)`. +fn merged_radios(specs: &[(&str, &str, f64)]) -> Document { + let mut doc = one_page(); + let page = page_refs(&doc)[0]; + let af = crate::author::ensure_form(&mut doc).unwrap(); + let mut made = Vec::new(); + for &(name, state, x) in specs { + let look = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(pdfcraft_cos::Dict::new(), b"0 g".to_vec()))); + let mut n = pdfcraft_cos::Dict::new(); + n.set(state.as_bytes().to_vec(), Object::Ref(look)); + n.set(b"Off".to_vec(), Object::Ref(look)); + let mut ap = pdfcraft_cos::Dict::new(); + ap.set(b"N".to_vec(), Object::Dict(n)); + let mut d = pdfcraft_cos::Dict::new(); + d.set(b"Type".to_vec(), Object::name("Annot")); + d.set(b"Subtype".to_vec(), Object::name("Widget")); + d.set(b"FT".to_vec(), Object::name("Btn")); + d.set(b"Ff".to_vec(), Object::Int(i64::from(flags::RADIO))); + d.set(b"T".to_vec(), pdfcraft_cos::PdfString::text(name)); + d.set(b"Rect".to_vec(), Object::Array(vec![x.into(), 600.into(), (x + 14.0).into(), 614.into()])); + d.set(b"P".to_vec(), Object::Ref(page)); + d.set(b"AP".to_vec(), Object::Dict(ap)); + d.set(b"AS".to_vec(), Object::name("Off")); + let r = doc.add(Object::Dict(d)); + doc.update_dict(af, |a| { + let mut list = a.get(b"Fields").and_then(|l| l.as_array().cloned()).unwrap_or_default(); + list.push(Object::Ref(r)); + a.set(b"Fields".to_vec(), Object::Array(list)); + }) + .unwrap(); + made.push(r); + } + doc.update_dict(page, |p| p.set(b"Annots".to_vec(), Object::Array(made.iter().map(|r| Object::Ref(*r)).collect()))).unwrap(); + doc +} + +/// A radio field that is its own (only) widget, as other writers make one-button groups, can +/// both join a group and be joined. +#[test] +fn radio_fields_that_are_their_own_widget_join_and_are_joined() { + let mut doc = merged_radios(&[("solo", "A", 50.0), ("other", "B", 80.0)]); + assert_eq!(rename_to(&mut doc, "other", "solo").unwrap(), "solo"); + let doc = reopen(&doc); + let all = fields(&doc); + assert_eq!(all.iter().map(|f| f.name.as_str()).collect::>(), ["solo"]); + let group = field(&all, "solo"); + assert_eq!(group.widgets.iter().filter_map(|w| w.on_state.clone()).collect::>(), ["A", "B"]); + // The joined field's widget became a kid; the moved one shed its field entries. + let kids: Vec = group.widgets.iter().map(|w| w.obj).collect(); + assert!(!kids.contains(&group.obj)); + let moved = doc.get(group.widgets[1].obj).as_dict().unwrap().clone(); + assert!(!moved.contains(b"T") && !moved.contains(b"FT") && moved.reference(b"Parent") == Some(group.obj)); + let mut on_page = page_widgets(&doc); + on_page.sort(); + let mut kids = kids; + kids.sort(); + assert_eq!(on_page, kids, "the page lists the widgets, not the field"); +} + +/// A group that is its own only widget keeps its button's actions when another button joins +/// it: the new widget takes `/A` and the widget triggers of `/AA`, and the field keeps its own. +#[test] +fn a_joined_merged_groups_button_keeps_its_actions() { + let mut doc = merged_radios(&[("solo", "A", 50.0), ("other", "B", 80.0)]); + let solo = field(&fields(&doc), "solo").obj; + let js = |code: &str| { + let mut a = pdfcraft_cos::Dict::new(); + a.set(b"S".to_vec(), Object::name("JavaScript")); + a.set(b"JS".to_vec(), pdfcraft_cos::PdfString::text(code)); + Object::Dict(a) + }; + let mut aa = pdfcraft_cos::Dict::new(); + aa.set(b"Fo".to_vec(), js("focus();")); + aa.set(b"V".to_vec(), js("validate();")); + let aa = doc.add(Object::Dict(aa)); + doc.update_dict(solo, |d| { + d.set(b"A".to_vec(), js("up();")); + d.set(b"AA".to_vec(), Object::Ref(aa)); + }) + .unwrap(); + rename_to(&mut doc, "other", "solo").unwrap(); + let doc = reopen(&doc); + assert_eq!( + field_actions(&doc, "solo").unwrap(), + [(Trigger::MouseUp, FieldAction::JavaScript("up();".into())), (Trigger::OnFocus, FieldAction::JavaScript("focus();".into()))] + ); + let group = field(&fields(&doc), "solo").clone(); + let fd = doc.get(group.obj).as_dict().unwrap().clone(); + let field_aa = doc.resolve(fd.get(b"AA").unwrap()).as_dict().unwrap().clone(); + assert!(!fd.contains(b"A") && field_aa.contains(b"V") && !field_aa.contains(b"Fo"), "the field keeps only its own trigger"); + let wd = doc.get(group.widgets[0].obj).as_dict().unwrap().clone(); + let widget_aa = doc.resolve(wd.get(b"AA").unwrap()).as_dict().unwrap().clone(); + assert!(widget_aa.contains(b"Fo") && !widget_aa.contains(b"V")); +} + +/// A joining button that was its own field keeps its widget triggers and drops the field's. +#[test] +fn a_joining_merged_button_sheds_its_fields_triggers() { + let mut doc = merged_radios(&[("solo", "A", 50.0), ("other", "B", 80.0)]); + let other = field(&fields(&doc), "other").obj; + let js = |code: &str| { + let mut a = pdfcraft_cos::Dict::new(); + a.set(b"S".to_vec(), Object::name("JavaScript")); + a.set(b"JS".to_vec(), pdfcraft_cos::PdfString::text(code)); + Object::Dict(a) + }; + let mut aa = pdfcraft_cos::Dict::new(); + aa.set(b"U".to_vec(), js("up();")); + aa.set(b"V".to_vec(), js("validate();")); + aa.set(b"C".to_vec(), js("calc();")); + let aa = doc.add(Object::Dict(aa)); + doc.update_dict(other, |d| d.set(b"AA".to_vec(), Object::Ref(aa))).unwrap(); + rename_to(&mut doc, "other", "solo").unwrap(); + let doc = reopen(&doc); + let group = field(&fields(&doc), "solo").clone(); + let moved = group.widgets.iter().find(|w| w.on_state.as_deref() == Some("B")).unwrap(); + let wd = doc.get(moved.obj).as_dict().unwrap().clone(); + let widget_aa = doc.resolve(wd.get(b"AA").unwrap()).as_dict().unwrap().clone(); + assert!(widget_aa.contains(b"U") && !widget_aa.contains(b"V") && !widget_aa.contains(b"C"), "only the widget's trigger stays"); + let mut once = merged_radios(&[("solo", "A", 50.0), ("other", "B", 80.0)]); + let other = field(&fields(&once), "other").obj; + let mut aa = pdfcraft_cos::Dict::new(); + aa.set(b"V".to_vec(), js("validate();")); + once.update_dict(other, |d| d.set(b"AA".to_vec(), Object::Dict(aa))).unwrap(); + rename_to(&mut once, "other", "solo").unwrap(); + let once = reopen(&once); + let group = field(&fields(&once), "solo").clone(); + let moved = group.widgets.iter().find(|w| w.on_state.as_deref() == Some("B")).unwrap(); + let wd = once.get(moved.obj).as_dict().unwrap().clone(); + assert!(!wd.contains(b"AA"), "a widget left with no trigger has no /AA"); +} + +/// However many "ChoiceN" names a group already uses, a joining button with a clashing export +/// value gets a free one, so the two buttons can still be chosen apart. +#[test] +fn a_joining_button_gets_a_free_export_value_when_choice_names_run_long() { + let mut doc = two_radio_groups("Choice1", "Choice1"); + let group = field(&fields(&doc), "Group1").clone(); + let model = doc.get(group.widgets[0].obj).as_dict().unwrap().clone(); + let look = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(pdfcraft_cos::Dict::new(), b"0 g".to_vec()))); + let mut kids = vec![Object::Ref(group.widgets[0].obj)]; + for i in 2..=10_000 { + let mut n = pdfcraft_cos::Dict::new(); + n.set(format!("Choice{i}").into_bytes(), Object::Ref(look)); + n.set(b"Off".to_vec(), Object::Ref(look)); + let mut ap = pdfcraft_cos::Dict::new(); + ap.set(b"N".to_vec(), Object::Dict(n)); + let mut w = model.clone(); + w.set(b"AP".to_vec(), Object::Dict(ap)); + w.remove(b"P"); + kids.push(Object::Ref(doc.add(Object::Dict(w)))); + } + doc.update_dict(group.obj, |d| d.set(b"Kids".to_vec(), Object::Array(kids))).unwrap(); + rename_to(&mut doc, "Group2", "Group1").unwrap(); + let group = field(&fields(&doc), "Group1").clone(); + let states: Vec = group.widgets.iter().filter_map(|w| w.on_state.clone()).collect(); + assert_eq!(states.len(), 10_001); + assert_eq!(states.last().map(String::as_str), Some("Choice10001")); +} + +/// Only a radio button joins by name: other clashes are refused as before, and a locked group +/// isn't changed. +#[test] +fn joining_by_name_is_only_for_radio_buttons_and_unlocked_groups() { + let mut doc = two_radio_groups("Yes", "No"); + add_field(&mut doc, 0, [50.0, 700.0, 250.0, 720.0], &NewField::Text { multiline: false }, Some("note")).unwrap(); + assert!(rename_to(&mut doc, "note", "Group1").is_err(), "a text field doesn't join a radio group"); + assert!(rename_to(&mut doc, "Group2", "note").is_err(), "a radio button doesn't join a text field"); + set_props(&mut doc, "Group1", &FieldProps { locked: Some(true), ..FieldProps::default() }).unwrap(); + assert!(rename_to(&mut doc, "Group2", "Group1").is_err(), "a locked group"); + assert_eq!(fields(&doc).len(), 3); +} + +/// `/I` of field `name`: the selected positions in `/Opt`, or `None` when absent. +fn selection_indices(doc: &Document, name: &str) -> Option> { + let f = field(&fields(doc), name).clone(); + let d = doc.get(f.obj).as_dict().cloned().unwrap(); + d.get(b"I").map(|i| doc.resolve(i).as_array().unwrap().iter().map(|o| o.as_int().unwrap()).collect()) +} + +#[test] +fn changing_a_lists_options_repoints_its_selected_indices() { + let mut doc = one_page(); + let list = + add_field(&mut doc, 0, [50.0, 600.0, 250.0, 680.0], &NewField::List { options: vec!["A".into(), "B".into(), "C".into()], multi: true }, None) + .unwrap(); + set_value(&mut doc, &list, &FieldValue::Choice(vec!["C".into(), "B".into()])).unwrap(); + assert_eq!(selection_indices(&doc, &list), Some(vec![1, 2])); + let f = field(&fields(&doc), &list).clone(); + doc.update_dict(f.obj, |d| d.set(b"TI".to_vec(), Object::Int(2))).unwrap(); + // Removing A moves B and C up one place. + let opts = |o: &[&str]| FieldProps { options: Some(o.iter().map(|s| s.to_string()).collect()), ..FieldProps::default() }; + set_props(&mut doc, &list, &opts(&["B", "C", "D"])).unwrap(); + assert_eq!(selection_indices(&doc, &list), Some(vec![0, 1])); + // Removing B leaves only C selected; the top row would point past the end. + set_props(&mut doc, &list, &opts(&["C"])).unwrap(); + assert_eq!(selection_indices(&doc, &list), Some(vec![0])); + assert_eq!(top_row(&doc, f.obj), Some(0), "the list scrolls back to its only row"); + // No selected value is offered any more. + set_props(&mut doc, &list, &opts(&["X", "Y"])).unwrap(); + assert_eq!(selection_indices(&doc, &list), None); + let doc = reopen(&doc); + assert_eq!(selection_indices(&doc, &list), None); +} + +fn top_row(doc: &Document, obj: ObjRef) -> Option { + doc.get(obj).as_dict().and_then(|d| d.get(b"TI")).and_then(|o| o.as_int()) +} + +#[test] +fn shrinking_a_lists_options_scrolls_a_widget_with_its_own_top_row() { + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [7 0 R] >>".into(), + "<< /Fields [6 0 R] /DA (/Helv 10 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(), + "<< /FT /Ch /T (pick) /Opt [(A) (B) (C) (D) (E) (F)] /V (F) /I [5] /Kids [7 0 R] >>".into(), + "<< /Type /Annot /Subtype /Widget /Parent 6 0 R /TI 5 /Rect [50 700 250 720] /P 3 0 R >>".into(), + ]; + let mut doc = document(&objs); + let widget = ObjRef::new(7, 0); + set_props(&mut doc, "pick", &FieldProps { options: Some(vec!["F".into(), "A".into()]), ..FieldProps::default() }).unwrap(); + assert_eq!(selection_indices(&doc, "pick"), Some(vec![0])); + // The widget's own /TI is read before the field's; row 5 no longer exists. + assert_eq!(top_row(&doc, widget), Some(0), "the widget scrolls to F, now row 0"); + let doc = reopen(&doc); + assert_eq!(top_row(&doc, widget), Some(0)); +} + +#[test] +fn each_widget_with_its_own_top_row_scrolls_by_itself() { + // Widget 7 shows from the field's top row; widget 8 keeps its own /TI 4. + let objs = |v: &str| -> Vec { + vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [7 0 R 8 0 R] >>".into(), + "<< /Fields [6 0 R] /DA (/Helv 10 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(), + format!("<< /FT /Ch /T (pick) /Opt [(A) (B) (C) (D) (E) (F)] {v} /Kids [7 0 R 8 0 R] >>"), + "<< /Type /Annot /Subtype /Widget /Parent 6 0 R /Rect [50 700 250 740] /P 3 0 R >>".into(), + "<< /Type /Annot /Subtype /Widget /Parent 6 0 R /TI 4 /Rect [50 600 250 620] /P 3 0 R >>".into(), + ] + }; + let (first, other) = (ObjRef::new(7, 0), ObjRef::new(8, 0)); + // The options shrink: the first widget already shows A, the other's row 4 is gone. + let mut doc = document(&objs("/V (A) /I [0]")); + set_props(&mut doc, "pick", &FieldProps { options: Some(vec!["A".into(), "B".into()]), ..FieldProps::default() }).unwrap(); + assert_eq!(top_row(&doc, first), None); + assert_eq!(top_row(&reopen(&doc), other), Some(0), "options: the other widget scrolls to A"); + // A new value the first widget already shows, below the other's view. + let mut doc = document(&objs("/V (B) /I [1]")); + set_value(&mut doc, "pick", &FieldValue::Choice(vec!["A".into()])).unwrap(); + assert_eq!(top_row(&doc, ObjRef::new(6, 0)), None, "the field's top row did not move"); + assert_eq!(top_row(&reopen(&doc), other), Some(0), "value: the other widget scrolls to A"); +} + +/// A list box's top row is chosen with the font size set in the same edit: at the larger size +/// fewer rows fit, so it scrolls further to keep the selection in view. +#[test] +fn a_list_boxs_top_row_follows_a_font_size_changed_with_its_items() { + let mut doc = document(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R] >>".into(), + "<< /Fields [6 0 R] /DA (/Helv 10 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(), + "<< /FT /Ch /T (pick) /Opt [(A) (B) (C) (D) (E)] /V (C) /I [2] /Type /Annot /Subtype /Widget /Rect [50 700 250 740] /P 3 0 R >>".into(), + ]); + let items = ["Z", "A", "B", "C", "D"].map(String::from).to_vec(); + set_props(&mut doc, "pick", &FieldProps { options: Some(items), font_size: Some(20.0), ..FieldProps::default() }).unwrap(); + let doc = reopen(&doc); + let g = field(&fields(&doc), "pick").clone(); + let w = &g.widgets[0]; + let rows = w.list.unwrap(); + let shown = rows.top..rows.top + rows.visible(w.rect[3] - w.rect[1]); + assert!(shown.contains(&3), "C is row 3; rows {shown:?} show"); +} + +#[test] +fn sorting_a_combo_boxs_items_repoints_its_selected_index() { + let mut doc = one_page(); + let combo = add_field( + &mut doc, + 0, + [50.0, 600.0, 250.0, 620.0], + &NewField::Combo { options: vec!["Pear".into(), "apple".into(), "Fig".into()], editable: false }, + None, + ) + .unwrap(); + set_value(&mut doc, &combo, &FieldValue::Choice(vec!["Pear".into()])).unwrap(); + assert_eq!(selection_indices(&doc, &combo), Some(vec![0])); + set_props(&mut doc, &combo, &FieldProps { flags: vec![(flags::SORT, true)], ..FieldProps::default() }).unwrap(); + // apple, Fig, Pear: the selection follows Pear to the end. + assert_eq!(selection_indices(&doc, &combo), Some(vec![2])); + assert_eq!(field(&fields(&doc), &combo).value, vec!["Pear".to_string()]); +} + +#[test] +fn sorting_keeps_which_of_two_items_with_one_export_value_is_selected() { + let mut doc = one_page(); + let list = add_field(&mut doc, 0, [50.0, 600.0, 250.0, 680.0], &NewField::List { options: vec!["a".into()], multi: false }, None).unwrap(); + let f = field(&fields(&doc), &list).clone(); + let item = |e: &str, shown: &str| Object::Array(vec![Object::String(PdfString::text(e)), Object::String(PdfString::text(shown))]); + // Zeta and Alpha both export "x"; `/I` says Zeta is the one selected. + doc.update_dict(f.obj, |d| { + d.set(b"Opt".to_vec(), Object::Array(vec![item("x", "Zeta"), item("x", "Alpha"), item("y", "Mid")])); + d.set(b"V".to_vec(), PdfString::text("x")); + d.set(b"I".to_vec(), Object::Array(vec![Object::Int(0)])); + }) + .unwrap(); + set_props(&mut doc, &list, &FieldProps { flags: vec![(flags::SORT, true)], ..FieldProps::default() }).unwrap(); + // Alpha, Mid, Zeta: the selection follows Zeta, not the first item exporting "x". + assert_eq!(selection_indices(&doc, &list), Some(vec![2])); +} + +#[test] +fn clearing_a_kids_default_does_not_bring_back_its_parents() { + let mut doc = fixture(); + let all = fields(&doc); + let city = field(&all, "address.city").clone(); + let parent = doc.get(city.obj).as_dict().unwrap().reference(b"Parent").unwrap(); + doc.update_dict(parent, |d| d.set(b"DV".to_vec(), PdfString::text("Main St"))).unwrap(); + set_props(&mut doc, "address.city", &FieldProps { default_value: Some(Some("Elm".into())), ..FieldProps::default() }).unwrap(); + assert_eq!(field(&fields(&doc), "address.city").default, ["Elm"]); + set_props(&mut doc, "address.city", &FieldProps { default_value: Some(None), ..FieldProps::default() }).unwrap(); + let mut doc = reopen(&doc); + let f = field(&fields(&doc), "address.city").clone(); + assert!(f.default.iter().all(String::is_empty), "no default, not the parent's: {:?}", f.default); + let own = doc.get(f.obj).as_dict().and_then(|d| d.get(b"DV")).and_then(|o| o.as_string()).map(PdfString::to_text); + assert_eq!(own.as_deref(), Some(""), "the kid holds an empty default of its own"); + set_value(&mut doc, "address.city", &FieldValue::Text("Oak".into())).unwrap(); + reset(&mut doc, None).unwrap(); + assert!(field(&fields(&doc), "address.city").value.iter().all(String::is_empty), "reset clears it"); + // Without a parent default, clearing still removes the key. + set_props(&mut doc, "name", &FieldProps { default_value: Some(None), ..FieldProps::default() }).unwrap(); + let name = field(&fields(&doc), "name").clone(); + assert!(doc.get(name.obj).as_dict().unwrap().get(b"DV").is_none()); +} + +#[test] +fn clearing_a_kids_value_does_not_bring_back_its_parents() { + // A text kid, then a list box kid, under a parent with both a value and a default. + for (ft, set) in [("/FT /Tx", FieldValue::Text("Oak".into())), ("/FT /Ch /Opt [(Main St) (Oak)]", FieldValue::Choice(vec!["Oak".into()]))] { + let mut doc = document(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R] >>".into(), + "<< /Fields [5 0 R] /DA (/Helv 0 Tf 0 g) >>".into(), + format!("<< /T (address) {ft} /V (Main St) /DV (Main St) /Kids [6 0 R] >>"), + "<< /Type /Annot /Subtype /Widget /T (city) /Parent 5 0 R /Rect [10 10 110 60] /P 3 0 R >>".into(), + ]); + set_props(&mut doc, "address.city", &FieldProps { default_value: Some(None), ..FieldProps::default() }).unwrap(); + set_value(&mut doc, "address.city", &set).unwrap(); + assert_eq!(field(&fields(&doc), "address.city").value, ["Oak"], "{ft}"); + reset(&mut doc, None).unwrap(); + let doc = reopen(&doc); + let f = field(&fields(&doc), "address.city").clone(); + assert!(f.value.iter().all(String::is_empty), "{ft}: reset leaves it empty, not the parent's: {:?}", f.value); + let mut doc = doc; + set_value(&mut doc, "address.city", &set).unwrap(); + let clear = if ft.contains("Ch") { FieldValue::Choice(Vec::new()) } else { FieldValue::Text(String::new()) }; + set_value(&mut doc, "address.city", &clear).unwrap(); + let f = field(&fields(&reopen(&doc)), "address.city").clone(); + if ft.contains("Ch") { + assert!(f.value.is_empty(), "{ft}: clearing leaves no choice selected: {:?}", f.value); + } else { + assert!(f.value.iter().all(String::is_empty), "{ft}: clearing leaves it empty: {:?}", f.value); + } + } +} + +#[test] +fn clearing_a_buttons_default_under_a_parent_with_one_leaves_it_off() { + // A check box, then a radio button (flags 49152, inherited from the parent). + for flags in ["", "/Ff 49152"] { + let mut doc = document(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R] >>".into(), + "<< /Fields [5 0 R] /DA (/Helv 0 Tf 0 g) >>".into(), + format!("<< /T (opts) /FT /Btn {flags} /DV /Yes /Kids [6 0 R] >>"), + "<< /Type /Annot /Subtype /Widget /T (a) /Parent 5 0 R /V /Off /AS /Off /Rect [10 10 25 25] /P 3 0 R /AP << /N << /Yes 7 0 R /Off 7 0 R >> >> >>".into(), + "<< /Length 0 >> +stream + +endstream".into(), + ]); + let kind = field(&fields(&doc), "opts.a").kind; + assert_eq!(kind == FieldKind::Radio, !flags.is_empty()); + set_props(&mut doc, "opts.a", &FieldProps { default_value: Some(None), ..FieldProps::default() }).unwrap(); + let f = field(&fields(&doc), "opts.a").clone(); + assert_eq!(doc.get(f.obj).as_dict().and_then(|d| d.get(b"DV")).and_then(|o| o.as_name()), Some(&b"Off"[..]), "{kind:?}"); + let on = if kind == FieldKind::Radio { FieldValue::Radio(Some("Yes".into())) } else { FieldValue::Check(true) }; + set_value(&mut doc, "opts.a", &on).unwrap(); + assert_eq!(field(&fields(&doc), "opts.a").value, ["Yes"]); + reset(&mut doc, None).unwrap(); + let f = field(&fields(&doc), "opts.a").clone(); + assert!(f.value.iter().all(String::is_empty), "{kind:?}: reset leaves it off, not the parent's Yes: {:?}", f.value); + } +} + +#[test] +fn editing_button_export_values_preserves_selection_and_appearance() { + let mut doc = fixture(); + set_value(&mut doc, "size", &FieldValue::Radio(Some("S".into()))).unwrap(); + let before = field(&fields(&doc), "size").clone(); + let appearances: Vec<_> = before.widgets.iter().map(|w| doc.get(w.obj).as_dict().unwrap().get(b"AP").cloned()).collect(); + set_props(&mut doc, "size", &FieldProps { export_value: Some((0, "Small å".into())), ..Default::default() }).unwrap(); + let after = field(&fields(&doc), "size").clone(); + assert_eq!(after.value, before.value); + assert_eq!(after.default, before.default); + assert_eq!(after.widgets, before.widgets); + assert_eq!(after.export_of(0), Some("Small å")); + assert_eq!(after.export_of(1), before.export_of(1)); + assert_eq!(after.export_values(), vec!["Small å"]); + assert_eq!(appearances, after.widgets.iter().map(|w| doc.get(w.obj).as_dict().unwrap().get(b"AP").cloned()).collect::>()); + let mut saved = reopen(&doc); + assert_eq!(field(&fields(&saved), "size").export_of(0), Some("Small å")); + set_value(&mut saved, "size", &FieldValue::Radio(Some("Small å".into()))).unwrap(); + reset(&mut saved, None).unwrap(); + assert_eq!(field(&fields(&saved), "size").value, before.default); + + set_value(&mut doc, "agree", &FieldValue::Check(true)).unwrap(); + set_props(&mut doc, "agree", &FieldProps { export_value: Some((0, "Accepted".into())), ..Default::default() }).unwrap(); + let check = field(&fields(&doc), "agree").clone(); + assert_eq!(check.value, vec!["Yes"]); + assert_eq!(check.export_values(), vec!["Accepted"]); + assert_eq!(field(&fields(&reopen(&doc)), "agree").export_of(0), Some("Accepted")); +} + +#[test] +fn invalid_button_export_edits_leave_document_unchanged() { + let mut doc = fixture(); + let before = write_incremental(&doc, &SaveOptions::default()).unwrap(); + for (field_name, index, value) in [("size", 99, "new"), ("size", 0, ""), ("size", 0, "Off"), ("size", 0, "L"), ("name", 0, "new")] { + assert!(set_props(&mut doc, field_name, &FieldProps { export_value: Some((index, value.into())), ..Default::default() }).is_err()); + assert_eq!(write_incremental(&doc, &SaveOptions::default()).unwrap(), before); + } +} + +#[test] +fn locked_button_export_edits_leave_the_document_unchanged() { + for name in ["agree", "size"] { + let mut doc = fixture(); + set_props(&mut doc, name, &FieldProps { locked: Some(true), ..Default::default() }).unwrap(); + let before = write_incremental(&doc, &SaveOptions::default()).unwrap(); + let error = set_props( + &mut doc, + name, + &FieldProps { export_value: Some((0, "Changed".into())), tooltip: Some("Must not change".into()), ..Default::default() }, + ) + .unwrap_err(); + assert!(error.to_string().contains("locked")); + assert_eq!(write_incremental(&doc, &SaveOptions::default()).unwrap(), before); + } +} diff --git a/crates/forms/src/values.rs b/crates/forms/src/values.rs new file mode 100644 index 0000000000..e8615567b6 --- /dev/null +++ b/crates/forms/src/values.rs @@ -0,0 +1,659 @@ +use super::appearances::{declares_pdfa, need_appearances}; +use super::read::{acroform, field_flags, fields, page_refs}; +use super::{ + Dict, Document, Field, FieldEvent, FieldKind, FieldValue, FormError, NoScripts, ObjRef, Object, PdfString, Scripts, Widget, af, appearance, + author, calc_order, flags, invalid, name_obj, scripting, +}; + +// ── writing ───────────────────────────────────────────────────────────────────────────────── + +/// Fill the field `name`. +pub fn set_value(doc: &mut Document, name: &str, value: &FieldValue) -> Result<(), FormError> { + set_value_with(doc, name, value, &mut NoScripts) +} + +/// [`set_value`], running the fields' JavaScript through `scripts`. +pub fn set_value_with(doc: &mut Document, name: &str, value: &FieldValue, scripts: &mut dyn Scripts) -> Result<(), FormError> { + let all = fields(doc); + if all.is_empty() { + return Err(FormError::NoForm); + } + let named: Vec = all.iter().enumerate().filter(|(_, f)| f.name == name).map(|(i, _)| i).collect(); + fill(doc, &all, &named, name, value, scripts)?; + recalculate_with(doc, scripts)?; + Ok(()) +} + +/// Fill several fields, as [`set_value`] fills each in turn, but reading the form once and +/// recalculating once, after the last value. Filling fields one by one reads the whole form twice +/// per field, which takes minutes on a form with thousands of fields. +/// +/// Returns each value's result, in order. The outer `Err` is the recalculation failing, after +/// which, as with [`set_value`], no value counts as filled (though each was written). +pub fn set_values(doc: &mut Document, values: &[(String, FieldValue)]) -> Result>, FormError> { + let mut scripts = NoScripts; + let mut form = NamedFields::read(doc); + // The field objects this batch wrote since the form was last read. + let mut written = std::collections::HashSet::new(); + let mut results = Vec::with_capacity(values.len()); + let mut filled = false; + for (name, value) in values { + if form.all.is_empty() { + results.push(Err(FormError::NoForm)); + continue; + } + // A field written earlier in the batch (named twice, or a twin's name) is read again, + // so this write starts from what the earlier one left, as it would one by one. + if form.objs(name).any(|o| written.contains(&o)) { + form = NamedFields::read(doc); + written.clear(); + } + let named = form.named(name); + let result = fill(doc, &form.all, named, name, value, &mut scripts); + written.extend(form.objs(name)); + filled |= result.is_ok(); + results.push(result); + } + if filled { + recalculate_with(doc, &mut scripts)?; + } + Ok(results) +} + +/// The form's fields, with the positions of each fully qualified name in them (in field order). +struct NamedFields { + all: Vec, + by_name: std::collections::HashMap>, +} + +impl NamedFields { + fn read(doc: &Document) -> Self { + let all = fields(doc); + let mut by_name: std::collections::HashMap> = std::collections::HashMap::new(); + for (i, f) in all.iter().enumerate() { + by_name.entry(f.name.clone()).or_default().push(i); + } + NamedFields { all, by_name } + } + + fn named(&self, name: &str) -> &[usize] { + self.by_name.get(name).map_or(&[], Vec::as_slice) + } + + /// The field objects named `name`. + fn objs<'a>(&'a self, name: &str) -> impl Iterator + 'a { + self.named(name).iter().filter_map(|&i| self.all.get(i)).map(|f| f.obj) + } +} + +/// Write `value` into the field `name`, without recalculating: `named` are the positions in +/// `all` (the form as read before this write) of the fields with that name, in field order. +fn fill(doc: &mut Document, all: &[Field], named: &[usize], name: &str, value: &FieldValue, scripts: &mut dyn Scripts) -> Result<(), FormError> { + let f = named.first().and_then(|&i| all.get(i)).ok_or_else(|| FormError::NoSuchField(name.into()))?; + if f.read_only() { + return Err(FormError::ReadOnly(name.into())); + } + write_value(doc, f, value, scripts)?; + author::form_fonts_as_objects(doc, all)?; + // Fields with the same fully qualified name are one field (§12.7.3.1). Writers that repeat a + // name as separate field dictionaries, instead of widgets under one parent, get the value + // the first one took after its scripts, without running them again. + let twins: Vec<&Field> = named.iter().filter_map(|&i| all.get(i)).filter(|t| t.obj != f.obj && t.kind == f.kind).collect(); + if !twins.is_empty() { + let taken = fields(doc).into_iter().find(|x| x.obj == f.obj).map(|x| x.value).unwrap_or_default(); + for t in twins { + share_value(doc, f.obj, t, &taken, scripts)?; + } + } + Ok(()) +} + +/// Give `twin` the value `source` holds: its `/V` (and a choice's `/I`), drawn in the twin's own +/// widgets. +fn share_value(doc: &mut Document, source: ObjRef, twin: &Field, value: &[String], scripts: &mut dyn Scripts) -> Result<(), FormError> { + match twin.kind { + FieldKind::CheckBox => { + let state = twin.widgets.iter().find_map(|w| w.on_state.clone()).unwrap_or_else(|| "Yes".into()); + set_states(doc, twin, (!value.is_empty()).then_some(state.as_str())) + } + FieldKind::Radio => { + let choice = value.first().filter(|c| twin.widgets.iter().any(|w| w.on_state.as_deref() == Some(c.as_str()))); + set_states(doc, twin, choice.map(String::as_str)) + } + FieldKind::Text | FieldKind::Combo | FieldKind::List => { + let src = doc.get(source).as_dict().cloned().unwrap_or_default(); + doc.update_dict(twin.obj, |d| { + for key in [&b"V"[..], b"I"] { + match src.get(key) { + Some(v) => d.set(key.to_vec(), v.clone()), + None => { + d.remove(key); + } + } + } + d.remove(b"RV"); + })?; + redraw(doc, twin, value, scripts) + } + FieldKind::PushButton | FieldKind::Signature => Ok(()), + } +} + +/// Run every field's Calculate script in the form's calculation order (`/CO`, then the other +/// calculated fields), as Acrobat does after any value changes. Returns how many changed. +pub fn recalculate(doc: &mut Document) -> Result { + recalculate_with(doc, &mut NoScripts) +} + +/// [`recalculate`], running custom Calculate (and Format) scripts through `scripts`. +pub fn recalculate_with(doc: &mut Document, scripts: &mut dyn Scripts) -> Result { + let mut all = fields(doc); + if !all.iter().any(calc_order::is_calculated) { + return Ok(0); + } + // By object: two fields can share a name in a malformed form, and each calculates. + let order: Vec = calc_order::run_order(doc, &all).0.into_iter().filter_map(|(i, _)| all.get(i)).map(|f| f.obj).collect(); + let mut changed = 0; + for obj in order { + let Some(f) = all.iter().find(|f| f.obj == obj).cloned() else { continue }; + let lookup = |n: &str| -> Vec { + let prefix = format!("{n}."); + all.iter().filter(|x| x.name == n || x.name.starts_with(&prefix)).flat_map(|x| x.value.first().cloned()).collect() + }; + let current = f.value.first().cloned().unwrap_or_default(); + let v = if let Some(js) = &f.actions.scripts.calculate { + let r = scripts.run(FieldEvent::Calculate, js, &f, ¤t, &all); + scripting::apply_changes(doc, &r.changes, &f.name)?; + if !r.changes.is_empty() { + all = fields(doc); + } + if !r.rc { + continue; + } + r.value + } else { + let Some(v) = af::calculate(&f.actions.calculate, &lookup) else { continue }; + v + }; + if current != v && matches!(f.kind, FieldKind::Text | FieldKind::Combo) { + doc.update_dict(f.obj, |d| d.set(b"V".to_vec(), PdfString::text(&v)))?; + redraw(doc, &f, std::slice::from_ref(&v), scripts)?; + if let Some(target) = all.iter_mut().find(|x| x.obj == obj) { + target.value = vec![v]; + } + changed += 1; + } + } + Ok(changed) +} + +/// The fields whose calculation differs between two readings of a form, matched by field object +/// and named as in `after`: the formulas an edit rewrote, such as a rename's. +pub fn calculations_changed(before: &[Field], after: &[Field]) -> Vec { + let before: std::collections::HashMap = before.iter().map(|f| (f.obj, &f.actions)).collect(); + after + .iter() + .filter(|f| before.get(&f.obj).is_some_and(|b| b.calculate != f.actions.calculate || b.scripts.calculate != f.actions.scripts.calculate)) + .map(|f| f.name.clone()) + .collect() +} + +pub(crate) fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scripts: &mut dyn Scripts) -> Result<(), FormError> { + match (f.kind, value) { + (FieldKind::Text, FieldValue::Text(t)) => { + if let Some(max) = f.max_len + && t.chars().count() > max + { + return invalid(format!("{:?} takes at most {max} characters", f.name)); + } + // Keystroke (on commit) and Validate, as Acrobat runs them before accepting a value. + let t = &af::keystroke(&f.actions.format, &f.name, t).map_err(FormError::Format)?; + af::validate(&f.actions.validate, t).map_err(FormError::Invalid)?; + let t = &scripting::accept(doc, f, t, scripts)?; + let shadow = t.is_empty() && crate::author::ancestor_sets(doc, f.obj, b"V"); + doc.update_dict(f.obj, |d| { + if t.is_empty() && !shadow { + d.remove(b"V"); + } else { + d.set(b"V".to_vec(), PdfString::text(t)); + } + d.remove(b"RV"); + })?; + redraw(doc, f, std::slice::from_ref(t), scripts) + } + (FieldKind::CheckBox, v) => { + let state = f.widgets.iter().find_map(|w| w.on_state.clone()).unwrap_or_else(|| "Yes".into()); + let on = match v { + FieldValue::Check(on) => *on, + // The on-state name or a yes/no word also work (agents, FDF-style data). + FieldValue::Text(t) | FieldValue::Radio(Some(t)) => { + let t = t.trim(); + if t == state || f.state_for(t).is_some() || ["yes", "true", "on", "1", "x", "checked"].contains(&t.to_lowercase().as_str()) { + true + } else if t.is_empty() || ["no", "false", "off", "0", "unchecked"].contains(&t.to_lowercase().as_str()) { + false + } else { + return invalid(format!("{:?} is a check box: use true or false", f.name)); + } + } + FieldValue::Radio(None) => false, + FieldValue::Choice(_) => return invalid(format!("{:?} is a check box: use true or false", f.name)), + }; + set_states(doc, f, on.then_some(state.as_str())) + } + (FieldKind::Radio, v) => { + let choice = match v { + FieldValue::Radio(c) => c.clone(), + FieldValue::Text(t) if t.is_empty() => None, + FieldValue::Text(t) => Some(t.clone()), + FieldValue::Check(false) => None, + _ => return invalid(format!("{:?} is a radio group: choose one of its options", f.name)), + }; + // An export value from /Opt selects its widget's on state. + let choice = choice.map(|c| f.state_for(&c).map(str::to_owned).unwrap_or(c)); + if let Some(c) = &choice + && !f.widgets.iter().any(|w| w.on_state.as_deref() == Some(c.as_str())) + { + let opts: Vec<&str> = (0..f.widgets.len()).filter_map(|i| f.export_of(i)).collect(); + return invalid(format!("{:?} has no option {c:?} (options: {})", f.name, opts.join(", "))); + } + if choice.is_none() && f.has(flags::NO_TOGGLE_TO_OFF) && !f.value.is_empty() { + return invalid(format!("{:?} must keep one option selected", f.name)); + } + set_states(doc, f, choice.as_deref()) + } + (FieldKind::Combo | FieldKind::List, FieldValue::Choice(_) | FieldValue::Text(_)) => { + let vals: Vec = match value { + FieldValue::Choice(v) => v.clone(), + FieldValue::Text(t) if t.is_empty() => Vec::new(), + FieldValue::Text(t) => vec![t.clone()], + other => return invalid(format!("{:?} can't take {other:?}", f.name)), + }; + if vals.len() > 1 && !(f.kind == FieldKind::List && f.has(flags::MULTI_SELECT)) { + return invalid(format!("{:?} takes a single value", f.name)); + } + // Accept export values or display texts; free text only in editable combo boxes. + let mut exports = Vec::new(); + for v in &vals { + match f.options.iter().find(|(e, d)| e == v || d == v) { + Some((e, _)) => exports.push(e.clone()), + None if f.kind == FieldKind::Combo && f.has(flags::EDIT) => exports.push(v.clone()), + None if f.options.is_empty() => exports.push(v.clone()), + None => { + let opts: Vec<&str> = f.options.iter().map(|(_, d)| d.as_str()).collect(); + return invalid(format!("{:?} has no option {v:?} (options: {})", f.name, opts.join(", "))); + } + } + } + let mut indices: Vec = + exports.iter().filter_map(|e| f.options.iter().position(|(x, _)| x == e)).map(|i| Object::Int(i as i64)).collect(); + // /I lists option indices in ascending order; /V keeps the caller's value order. + // Every entry above is an integer, so this sorts by its numeric option index. + indices.sort_unstable_by_key(Object::as_int); + // A list box scrolls (`/TI`) so the picked options are among the rows it draws, as + // in other viewers: a pick below the last visible row would otherwise not show (#1524). + let picked: Vec = indices.iter().filter_map(|i| i.as_int()).filter_map(|i| usize::try_from(i).ok()).collect(); + let top = list_scroll(f, &picked, f.options.len()); + // An empty value of its own keeps a parent field's /V from showing through. + let shadow = exports.is_empty() && crate::author::ancestor_sets(doc, f.obj, b"V"); + doc.update_dict(f.obj, |d| { + match exports.as_slice() { + [] if shadow => d.set(b"V".to_vec(), PdfString::text("")), + [] => { + d.remove(b"V"); + } + [one] => d.set(b"V".to_vec(), PdfString::text(one)), + many => d.set(b"V".to_vec(), Object::Array(many.iter().map(|e| Object::String(PdfString::text(e))).collect())), + } + if indices.is_empty() { + d.remove(b"I"); + } else { + d.set(b"I".to_vec(), Object::Array(indices)); + } + if let Some((old, new)) = top + && old != new + { + d.set(b"TI".to_vec(), Object::Int(i64::try_from(new).unwrap_or(0))); + } + })?; + scroll_widgets(doc, f, &picked, f.options.len())?; + redraw(doc, f, &exports, scripts) + } + (FieldKind::PushButton, _) => invalid(format!("{:?} is a button; it has no value", f.name)), + (FieldKind::Signature, _) => invalid(format!("{:?} is a signature field; sign it with Fill & Sign or a digital ID", f.name)), + (kind, v) => invalid(format!("{:?} is a {kind:?} field and can't take {v:?}", f.name)), + } +} + +/// A list box's first visible row (`/TI`) before and after scrolling so the `picked` positions of +/// its `count` options are among the rows it draws, as in other viewers: a pick below the last +/// visible row would otherwise not show (#1524). `None` for any other field. +pub(crate) fn list_scroll(f: &Field, picked: &[usize], count: usize) -> Option<(usize, usize)> { + f.widgets.first().filter(|_| f.kind == FieldKind::List).and_then(|w| widget_scroll(w, picked, count)) +} + +/// A list box widget's first row before and after scrolling so `picked` shows. +fn widget_scroll(w: &Widget, picked: &[usize], count: usize) -> Option<(usize, usize)> { + w.list.map(|rows| { + let height = if matches!(w.rotation, 90 | 270) { w.rect[2] - w.rect[0] } else { w.rect[3] - w.rect[1] }; + (rows.top, appearance::ListRows::top_showing(rows.top, picked, rows.visible(height), count)) + }) +} + +/// Scroll every widget of `f` with its own `/TI` (read before the field's) by its own rows and +/// size, so `picked` shows in each. +pub(crate) fn scroll_widgets(doc: &mut Document, f: &Field, picked: &[usize], count: usize) -> Result<(), FormError> { + for w in f.widgets.iter().filter(|w| w.obj != f.obj) { + if doc.get(w.obj).as_dict().is_some_and(|d| d.contains(b"TI")) + && let Some((old, new)) = widget_scroll(w, picked, count) + && old != new + { + doc.update_dict(w.obj, |d| d.set(b"TI".to_vec(), Object::Int(i64::try_from(new).unwrap_or(0))))?; + } + } + Ok(()) +} + +/// Check boxes and radio buttons: `/V` on the field, `/AS` on each widget. +fn set_states(doc: &mut Document, f: &Field, on: Option<&str>) -> Result<(), FormError> { + let v = on.unwrap_or("Off"); + doc.update_dict(f.obj, |d| d.set(b"V".to_vec(), name_obj(v)))?; + for w in &f.widgets { + let state = match (on, w.on_state.as_deref()) { + (Some(c), Some(s)) if c == s => s, + _ => "Off", + }; + // A widget without appearances for its states gets PdfCraft's own. + let has_ap = doc.get(w.obj).as_dict().and_then(|d| d.get(b"AP").cloned()).is_some(); + if !has_ap { + let on_name = w.on_state.clone().unwrap_or_else(|| on.unwrap_or("Yes").to_string()); + let ap = appearance::check_box_states(doc, w, f.kind, &on_name, &f.da); + doc.update_dict(w.obj, |d| d.set(b"AP".to_vec(), Object::Dict(ap)))?; + } + doc.update_dict(w.obj, |d| d.set(b"AS".to_vec(), name_obj(state)))?; + } + Ok(()) +} + +/// Copy an existing appearance dictionary before replacing its normal appearance. +fn appearance_dict(doc: &Document, widget: ObjRef) -> Dict { + doc.get(widget).as_dict().and_then(|d| d.get(b"AP").map(|a| doc.resolve(a))).and_then(|a| a.as_dict().cloned()).unwrap_or_default() +} + +/// `widget`'s appearance dictionary (a copy, so a shared one is left alone) with the freshly +/// drawn entries of `fresh` (its `/N`). Other entries are kept, but the old down and rollover +/// appearances (`/D`, `/R`) would show the old value, caption or colour on press or hover, so +/// they go — except, for check boxes and radio buttons (whose `/N` is a dictionary of states), +/// the states the new `/N` still draws under the same names. +pub(crate) fn merged_appearance(doc: &Document, widget: ObjRef, fresh: &Dict) -> Dict { + let mut apd = appearance_dict(doc, widget); + // Only a dictionary of states counts (`as_dict` would also see a stream's own dictionary). + let states_of = |o: &Object| match &*doc.resolve(o) { + Object::Dict(d) => Some(d.clone()), + _ => None, + }; + let states: Option>> = fresh.get(b"N").and_then(states_of).map(|d| d.iter().map(|(k, _)| k.clone()).collect()); + for key in [&b"D"[..], &b"R"[..]] { + if fresh.contains(key) { + continue; + } + let kept = states.as_ref().and_then(|names| { + let old = apd.get(key).and_then(states_of)?; + let mut d = Dict::new(); + for (k, v) in old.iter().filter(|(k, _)| names.contains(k)) { + d.set(k.clone(), v.clone()); + } + (!d.is_empty()).then_some(d) + }); + match kept { + Some(d) => apd.set(key.to_vec(), Object::Dict(d)), + None => { + apd.remove(key); + } + } + } + for (k, v) in fresh.iter() { + apd.set(k.clone(), v.clone()); + } + apd +} + +/// Regenerate the normal appearance of every widget of a text or choice field. +fn redraw(doc: &mut Document, f: &Field, values: &[String], scripts: &mut dyn Scripts) -> Result<(), FormError> { + let shown = match values { + [one] if matches!(f.kind, FieldKind::Text | FieldKind::Combo) => scripting::formatted(doc, f, one, scripts), + _ => None, + }; + let in_place = f.kind == FieldKind::Text && f.has(flags::PASSWORD); + let mut undrawn = false; + for w in &f.widgets { + let (stream, missing) = match &shown { + Some(s) => appearance::field_appearance_drawn(doc, f, w, std::slice::from_ref(s), false), + None => appearance::field_appearance_drawn(doc, f, w, values, true), + }; + undrawn |= missing.is_some(); + let existing_ap = in_place + .then(|| { + doc.get(w.obj) + .as_dict() + .and_then(|d| d.get(b"AP").map(|a| doc.resolve(a))) + .and_then(|a| a.as_dict().and_then(|a| a.get(b"N").cloned())) + .and_then(|n| n.as_ref()) + .filter(|r| doc.is_edited(r.num) && matches!(&*doc.get(*r), Object::Stream(_))) + }) + .flatten(); + let ap = match existing_ap { + Some(r) => { + doc.set(r, Object::Stream(stream)); + r + } + None => doc.add(Object::Stream(stream)), + }; + let mut fresh = Dict::new(); + fresh.set(b"N".to_vec(), Object::Ref(ap)); + let apd = merged_appearance(doc, w.obj, &fresh); + doc.update_dict(w.obj, |d| { + d.set(b"AP".to_vec(), Object::Dict(apd)); + d.remove(b"AS"); + pdfcraft_fonts::set_undrawn_mark(d, missing); + })?; + } + if undrawn { + ask_viewers_to_redraw(doc)?; + } + Ok(()) +} + +/// A value its widget's font can't show is stored as typed and drawn with `?` (as Acrobat keeps +/// such a value): `/NeedAppearances` then asks other viewers to draw the fields from their values +/// with fonts that have those characters (the interactive form dictionary, §12.7.3). Not in a +/// document that declares PDF/A, which forbids it: there the widget's mark +/// (`pdfcraft_fonts::UNDRAWN_MARK`) is all, and the edit's report says the `?` shows everywhere. +pub(crate) fn ask_viewers_to_redraw(doc: &mut Document) -> Result<(), FormError> { + if declares_pdfa(doc) || need_appearances(doc) { + return Ok(()); + } + let Some(root) = doc.root() else { return Ok(()) }; + let entry = doc.get(root).as_dict().and_then(|c| c.get(b"AcroForm").cloned()); + match entry { + Some(Object::Ref(r)) => doc.update_dict(r, |d| d.set(b"NeedAppearances".to_vec(), Object::Bool(true)))?, + Some(Object::Dict(mut af)) => { + af.set(b"NeedAppearances".to_vec(), Object::Bool(true)); + doc.update_dict(root, |c| c.set(b"AcroForm".to_vec(), Object::Dict(af)))?; + } + _ => {} + } + Ok(()) +} + +/// Text fields with the Password flag (inherited or their own) whose value (`/V`, inherited or +/// their own) is not empty and not their default value (`/DV`, which the form's author wrote and +/// a save restores), or one of whose separate widgets carries a `/V`. +fn password_fields(doc: &Document) -> Vec { + if !has_password_flag(doc) { + return Vec::new(); + } + let on_widget = |f: &Field| f.widgets.iter().any(|w| w.obj != f.obj && doc.get(w.obj).as_dict().is_some_and(|d| d.get(b"V").is_some())); + let typed = |f: &Field| f.value.iter().any(|v| !v.is_empty()) && f.value.first() != f.default.first(); + fields(doc).into_iter().filter(|f| f.kind == FieldKind::Text && f.has(flags::PASSWORD) && (typed(f) || on_widget(f))).collect() +} + +/// Whether any field dictionary sets the Password flag (#3943 review). A field that inherits the +/// flag has an ancestor that sets it, so that ancestor is found instead; a kid clearing the flag +/// again only makes the answer cautious. It reads only `/Ff`, `/Kids` and `/Parent`, so a save +/// or recovery snapshot of a form without such a field doesn't build every [`Field`]. Like +/// [`fields`], it looks at the form's field tree and at the widgets on the pages, which may be +/// missing from `/Fields`. Each dictionary is visited once and at most 64 levels deep. +pub(crate) fn has_password_flag(doc: &Document) -> bool { + let sets_it = |r: ObjRef| doc.get(r).as_dict().and_then(|d| field_flags(doc, d)).is_some_and(|ff| ff & flags::PASSWORD != 0); + let top = acroform(doc).and_then(|af| af.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned())).unwrap_or_default(); + let mut stack: Vec<(Object, usize)> = top.into_iter().map(|o| (o, 0)).collect(); + let mut seen = std::collections::HashSet::new(); + while let Some((o, depth)) = stack.pop() { + let Object::Ref(r) = o else { continue }; + if depth > 64 || !seen.insert(r) { + continue; + } + if sets_it(r) { + return true; + } + let kids = doc.get(r).as_dict().and_then(|d| d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned())).unwrap_or_default(); + stack.extend(kids.into_iter().map(|k| (k, depth + 1))); + } + // Widgets on the pages, and the fields above them. + page_refs(doc).into_iter().any(|p| { + let annots = + doc.get(p).as_dict().and_then(|d| d.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned())).unwrap_or_default(); + annots.iter().filter_map(Object::as_ref).filter(|r| !seen.contains(r)).any(|r| field_chain(doc, r).into_iter().any(sets_it)) + }) +} + +/// Whether any text field with the Password flag holds a value in `doc`. +pub fn has_password_values(doc: &Document) -> bool { + !password_fields(doc).is_empty() +} + +/// The typed values of the text fields with the Password flag, as (name, text) pairs, for +/// [`restore_password_values`] after the document was rebased on a file written without them. +pub fn password_values(doc: &Document) -> Vec<(String, String)> { + password_fields(doc).into_iter().filter_map(|f| f.value.first().map(|v| (f.name.clone(), v.clone()))).collect() +} + +/// Put values from [`password_values`] back into the open document, in memory only. A field +/// that is gone or read-only keeps what it has. +pub fn restore_password_values(doc: &mut Document, values: &[(String, String)]) -> Result<(), FormError> { + for (name, v) in values { + match set_value(doc, name, &FieldValue::Text(v.clone())) { + Ok(()) | Err(FormError::NoSuchField(_) | FormError::ReadOnly(_)) => {} + Err(e) => return Err(e), + } + } + Ok(()) +} + +/// `r` and its field ancestors (`/Parent`), nearest first, at most 64 deep and without repeats. +fn field_chain(doc: &Document, r: ObjRef) -> Vec { + let mut chain = vec![r]; + let mut cur = r; + while chain.len() < 64 { + let Some(Object::Ref(parent)) = doc.get(cur).as_dict().and_then(|d| d.get(b"Parent").cloned()) else { break }; + if chain.contains(&parent) { + break; + } + chain.push(parent); + cur = parent; + } + chain +} + +/// Remove the values of the text fields with the Password flag, for a copy of the document about +/// to be written to a file (#3926). ISO 32000-2 §12.7.5.3, Table 231, Password flag, note: "it is +/// imperative that PDF processors never store the value of the text field in the PDF file if +/// this flag is set". As Acrobat saves such a field (#3943 review, black-box test): a field with +/// a default value (`/DV`, which the form's author wrote) gets it back as its value, one without +/// is left empty, and its appearance is drawn again from that value, so the masking characters +/// no longer show how long the typed password was. `/DV` and the field's scripts stay. +/// +/// A value can sit on the field, on one of its widgets, or on an ancestor it inherits from +/// (`/V` is inheritable). Each is removed; an ancestor's only when every field inheriting from it +/// is such a password field. Where an ancestor's value is shared with other fields and stays, the +/// password field gets an empty value of its own so it no longer inherits it. Returns how many +/// fields were cleared. +pub fn clear_password_values(doc: &mut Document) -> Result { + let targets = password_fields(doc); + if targets.is_empty() { + return Ok(0); + } + let is_password = |f: &Field| f.kind == FieldKind::Text && f.has(flags::PASSWORD); + // Each field's ancestors, found once: only values change below, never `/Parent` (#3943 + // review: walking them again for every ancestor of every target was quadratic). + let chains: Vec<(bool, Vec)> = fields(doc).iter().map(|g| (is_password(g), field_chain(doc, g.obj))).collect(); + for f in &targets { + let chain = field_chain(doc, f.obj); + for w in &f.widgets { + if w.obj != f.obj { + doc.update_dict(w.obj, |d| { + d.remove(b"V"); + })?; + } + } + doc.update_dict(f.obj, |d| { + d.remove(b"V"); + })?; + let mut shared_value_kept = false; + for a in chain.iter().skip(1) { + if doc.get(*a).as_dict().is_none_or(|d| d.get(b"V").is_none()) { + continue; + } + let exclusive = chains.iter().filter(|(_, chain)| chain.contains(a)).all(|(password, _)| *password); + if exclusive { + doc.update_dict(*a, |d| { + d.remove(b"V"); + })?; + } else { + shared_value_kept = true; + } + } + let restored: Vec = f.default.first().cloned().into_iter().collect(); + match restored.first() { + Some(dv) => doc.update_dict(f.obj, |d| d.set(b"V".to_vec(), PdfString::text(dv)))?, + None if shared_value_kept => doc.update_dict(f.obj, |d| d.set(b"V".to_vec(), PdfString::text("")))?, + None => {} + } + redraw(doc, f, &restored, &mut NoScripts)?; + } + Ok(targets.len()) +} + +/// Acrobat's Clear form: every field (or the named ones) back to its default value. +pub fn reset(doc: &mut Document, names: Option<&[String]>) -> Result { + let all = fields(doc); + if all.is_empty() { + return Err(FormError::NoForm); + } + if let Some(n) = names + && let Some(missing) = n.iter().find(|n| !all.iter().any(|f| &f.name == *n)) + { + return Err(FormError::NoSuchField(missing.clone())); + } + let mut changed = 0; + for f in all.iter().filter(|f| names.is_none_or(|n| n.contains(&f.name))) { + if f.value == f.default { + continue; + } + let v = match f.kind { + FieldKind::Text => FieldValue::Text(f.default.first().cloned().unwrap_or_default()), + FieldKind::CheckBox => FieldValue::Check(!f.default.is_empty()), + FieldKind::Radio => FieldValue::Radio(f.default.first().cloned()), + FieldKind::Combo | FieldKind::List => FieldValue::Choice(f.default.clone()), + FieldKind::PushButton | FieldKind::Signature => continue, + }; + // A reset is not blocked by NoToggleToOff: go through the writer directly. + let mut tmp = f.clone(); + tmp.flags &= !flags::NO_TOGGLE_TO_OFF; + write_value(doc, &tmp, &v, &mut NoScripts)?; + changed += 1; + } + recalculate(doc)?; + Ok(changed) +} diff --git a/crates/js/src/document.rs b/crates/js/src/document.rs new file mode 100644 index 0000000000..e977457e2b --- /dev/null +++ b/crates/js/src/document.rs @@ -0,0 +1,224 @@ +use super::host::{arg, colour, error, function, host, read, s, set_value, strings, text, value_of, write}; +use super::model::{DISPLAY_HIDDEN, DISPLAY_VISIBLE, FieldType, Request}; +use super::{Attribute, Context, JsArray, JsObject, JsResult, JsString, JsValue, NativeFunction, ObjectInitializer, js_string}; + +macro_rules! getter { + ($ctx:expr, $name:expr, |$f:ident, $c:ident| $body:expr) => {{ + fn get(_: &JsValue, _: &[JsValue], name: &JsString, $c: &mut Context) -> JsResult { + let $f = read($c, name)?; + Ok($body) + } + function($ctx, NativeFunction::from_copy_closure_with_captures(get, $name.clone())) + }}; +} + +pub(crate) fn field_object(ctx: &mut Context, name: &str) -> JsObject { + let n = JsString::from(name); + let value_get = getter!(ctx, n, |f, c| value_of(&f, c)); + let value_set = { + fn set(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { + let vals = strings(&arg(args, 0), c)?; + write(c, name, |f| set_value(f, vals))?; + Ok(JsValue::undefined()) + } + function(ctx, NativeFunction::from_copy_closure_with_captures(set, n.clone())) + }; + let as_string = getter!(ctx, n, |f, _c| s(&match f.kind { + FieldType::CheckBox | FieldType::RadioButton => f.value.first().cloned().unwrap_or_else(|| "Off".into()), + _ => f.value.join(","), + })); + let default = getter!(ctx, n, |f, _c| s(&f.default.first().cloned().unwrap_or_default())); + let kind = getter!(ctx, n, |f, _c| s(f.kind.name())); + let fname = getter!(ctx, n, |f, _c| s(&f.name)); + let readonly_get = getter!(ctx, n, |f, _c| JsValue::from(f.readonly)); + let readonly_set = { + fn set(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { + let on = arg(args, 0).to_boolean(); + write(c, name, |f| f.readonly = on)?; + Ok(JsValue::undefined()) + } + function(ctx, NativeFunction::from_copy_closure_with_captures(set, n.clone())) + }; + let required_get = getter!(ctx, n, |f, _c| JsValue::from(f.required)); + let required_set = { + fn set(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { + let on = arg(args, 0).to_boolean(); + write(c, name, |f| f.required = on)?; + Ok(JsValue::undefined()) + } + function(ctx, NativeFunction::from_copy_closure_with_captures(set, n.clone())) + }; + let display_get = getter!(ctx, n, |f, _c| JsValue::from(f.display)); + let display_set = { + fn set(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { + let d = arg(args, 0).to_number(c)? as i32; + write(c, name, |f| f.display = d.clamp(0, 3))?; + Ok(JsValue::undefined()) + } + function(ctx, NativeFunction::from_copy_closure_with_captures(set, n.clone())) + }; + let hidden_get = getter!(ctx, n, |f, _c| JsValue::from(f.display == DISPLAY_HIDDEN)); + let hidden_set = { + fn set(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { + let on = arg(args, 0).to_boolean(); + write(c, name, |f| f.display = if on { DISPLAY_HIDDEN } else { DISPLAY_VISIBLE })?; + Ok(JsValue::undefined()) + } + function(ctx, NativeFunction::from_copy_closure_with_captures(set, n.clone())) + }; + let num_items = getter!(ctx, n, |f, _c| JsValue::from(f.options.len() as f64)); + let char_limit = getter!(ctx, n, |f, _c| JsValue::from(f.char_limit.unwrap_or(0) as f64)); + let export_values = getter!(ctx, n, |f, c| { + let vals: Vec = f.options.iter().map(|o| s(&o.0)).collect(); + JsArray::from_iter(vals, c).into() + }); + let indices = getter!(ctx, n, |f, c| { + let idx: Vec = f.options.iter().enumerate().filter(|(_, o)| f.value.contains(&o.0)).map(|(i, _)| JsValue::from(i as f64)).collect(); + if idx.len() == 1 { idx[0].clone() } else { JsArray::from_iter(idx, c).into() } + }); + let text_color_get = getter!(ctx, n, |f, c| colour(&f.text_color, c)); + let text_color_set = { + fn set(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { + let v = strings(&arg(args, 0), c)?; + write(c, name, |f| f.text_color = Some(v))?; + Ok(JsValue::undefined()) + } + function(ctx, NativeFunction::from_copy_closure_with_captures(set, n.clone())) + }; + let fill_color_get = getter!(ctx, n, |f, c| colour(&f.fill_color, c)); + let fill_color_set = { + fn set(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { + let v = strings(&arg(args, 0), c)?; + write(c, name, |f| f.fill_color = Some(v))?; + Ok(JsValue::undefined()) + } + function(ctx, NativeFunction::from_copy_closure_with_captures(set, n.clone())) + }; + + fn get_item_at(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { + let f = read(c, name)?; + let i = arg(args, 0).to_number(c)?; + // bExportValue defaults to true. + let export = args.get(1).is_none_or(JsValue::to_boolean); + let i = if i < 0.0 { f.options.len().saturating_sub(1) } else { i as usize }; + let o = f.options.get(i).ok_or_else(|| error(format!("{} has no item {i}", f.name)))?; + Ok(s(if export { &o.0 } else { &o.1 })) + } + fn is_box_checked(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { + let f = read(c, name)?; + let i = arg(args, 0).to_number(c)? as usize; + let on = f.options.get(i).is_some_and(|o| f.value.first() == Some(&o.0)); + Ok(JsValue::from(on)) + } + fn check_this_box(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { + let f = read(c, name)?; + let i = arg(args, 0).to_number(c)? as usize; + let on = args.get(1).is_none_or(JsValue::to_boolean); + let export = f.options.get(i).map(|o| o.0.clone()).ok_or_else(|| error(format!("{} has no widget {i}", f.name)))?; + write(c, name, |f| { + if on { + f.value = vec![export]; + } else if f.value.first() == Some(&export) { + f.value.clear(); + } + })?; + Ok(JsValue::undefined()) + } + fn set_focus(_: &JsValue, _: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { + host(c)?.borrow_mut().request(Request::Focus(name.to_std_string_escaped())); + Ok(JsValue::undefined()) + } + + let rw = Attribute::CONFIGURABLE | Attribute::ENUMERABLE; + let get_item_at = NativeFunction::from_copy_closure_with_captures(get_item_at, n.clone()); + let is_box_checked = NativeFunction::from_copy_closure_with_captures(is_box_checked, n.clone()); + let check_this_box = NativeFunction::from_copy_closure_with_captures(check_this_box, n.clone()); + let set_focus = NativeFunction::from_copy_closure_with_captures(set_focus, n.clone()); + ObjectInitializer::new(ctx) + .accessor(js_string!("value"), Some(value_get), Some(value_set), rw) + .accessor(js_string!("valueAsString"), Some(as_string), None, rw) + .accessor(js_string!("defaultValue"), Some(default), None, rw) + .accessor(js_string!("type"), Some(kind), None, rw) + .accessor(js_string!("name"), Some(fname), None, rw) + .accessor(js_string!("readonly"), Some(readonly_get), Some(readonly_set), rw) + .accessor(js_string!("required"), Some(required_get), Some(required_set), rw) + .accessor(js_string!("display"), Some(display_get), Some(display_set), rw) + .accessor(js_string!("hidden"), Some(hidden_get), Some(hidden_set), rw) + .accessor(js_string!("numItems"), Some(num_items), None, rw) + .accessor(js_string!("charLimit"), Some(char_limit), None, rw) + .accessor(js_string!("exportValues"), Some(export_values), None, rw) + .accessor(js_string!("currentValueIndices"), Some(indices), None, rw) + .accessor(js_string!("textColor"), Some(text_color_get), Some(text_color_set), rw) + .accessor(js_string!("fillColor"), Some(fill_color_get), Some(fill_color_set), rw) + .function(get_item_at, js_string!("getItemAt"), 2) + .function(is_box_checked, js_string!("isBoxChecked"), 1) + .function(check_this_box, js_string!("checkThisBox"), 2) + .function(set_focus, js_string!("setFocus"), 0) + .build() +} + +// ── the document (global object) ──────────────────────────────────────────────────────────── + +pub(crate) fn get_field(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let name = text(&arg(args, 0), ctx)?; + let exists = host(ctx)?.borrow().fields.iter().any(|f| f.name == name); + if exists { + return Ok(field_object(ctx, &name).into()); + } + // A parent name (e.g. "total" for "total.0"): its first kid stands in, as for groups. + let prefix = format!("{name}."); + let kid = host(ctx)?.borrow().fields.iter().find(|f| f.name.starts_with(&prefix)).map(|f| f.name.clone()); + Ok(kid.map_or(JsValue::null(), |k| field_object(ctx, &k).into())) +} + +pub(crate) fn get_nth_field_name(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let i = arg(args, 0).to_number(ctx)?; + let name = host(ctx)?.borrow().fields.get(i.max(0.0) as usize).map(|f| f.name.clone()); + Ok(name.map_or(JsValue::null(), |n| s(&n))) +} + +pub(crate) fn reset_form(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let names = strings(&arg(args, 0), ctx)?; + host(ctx)?.borrow_mut().request(Request::Reset(names)); + Ok(JsValue::undefined()) +} + +pub(crate) fn print(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { + host(ctx)?.borrow_mut().request(Request::Print); + Ok(JsValue::undefined()) +} + +pub(crate) fn submit_form(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let a = arg(args, 0); + let url = match a.as_object() { + Some(o) if !o.is_array() => { + let u = o.get(js_string!("cURL"), ctx)?; + text(&u, ctx)? + } + _ => text(&a, ctx)?, + }; + host(ctx)?.borrow_mut().request(Request::Submit(url)); + Ok(JsValue::undefined()) +} + +pub(crate) fn noop(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { + Ok(JsValue::undefined()) +} + +pub(crate) fn num_fields(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { + Ok(JsValue::from(host(ctx)?.borrow().fields.len() as f64)) +} + +pub(crate) fn page_num_get(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { + Ok(JsValue::from(host(ctx)?.borrow().doc.page as f64)) +} + +pub(crate) fn page_num_set(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let n = arg(args, 0).to_number(ctx)?; + let mut h = host(ctx)?.borrow_mut(); + let last = h.doc.num_pages.saturating_sub(1); + let p = (n.max(0.0) as usize).min(last); + h.doc.page = p; + h.request(Request::GoToPage(p)); + Ok(JsValue::undefined()) +} diff --git a/crates/js/src/format.rs b/crates/js/src/format.rs new file mode 100644 index 0000000000..7f207b1916 --- /dev/null +++ b/crates/js/src/format.rs @@ -0,0 +1,620 @@ +use super::host::{arg, error, s, text}; +use super::run::{MAX_PRINTF_OUTPUT_BYTES, MAX_PRINTF_WIDTH_PRECISION}; +use super::{Context, JsDate, JsResult, JsString, JsValue, formcalc}; + +/// `util.printf(cFormat, …)`: `%[,n][+ 0#][width][.prec](d|f|s|x)`, where `,0` groups +/// thousands with commas (the default), `,1` doesn't, `,2` uses `.` and `,`, `,3` uses `.` +/// without grouping. +pub(crate) fn printf(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let fmt = text(&arg(args, 0), ctx)?; + let mut out = String::new(); + let check_output = |len: usize| -> JsResult<()> { + if len > MAX_PRINTF_OUTPUT_BYTES { + return Err(error(format!("util.printf output exceeds {MAX_PRINTF_OUTPUT_BYTES} bytes"))); + } + Ok(()) + }; + let mut next = 1; + let mut chars = fmt.chars().peekable(); + while let Some(c) = chars.next() { + if c != '%' { + check_output(out.len().saturating_add(c.len_utf8()))?; + out.push(c); + continue; + } + if chars.peek() == Some(&'%') { + chars.next(); + check_output(out.len().saturating_add(1))?; + out.push('%'); + continue; + } + let mut sep = 1u8; + if chars.peek() == Some(&',') { + chars.next(); + sep = chars.next().and_then(|d| d.to_digit(10)).unwrap_or(0) as u8; + } + let (mut plus, mut space, mut zero) = (false, false, false); + while let Some(&f) = chars.peek() { + match f { + '+' => plus = true, + ' ' => space = true, + '0' => zero = true, + '#' => {} + _ => break, + } + chars.next(); + } + let mut width = 0usize; + while let Some(d) = chars.peek().and_then(|d| d.to_digit(10)) { + width = width.saturating_mul(10).saturating_add(d as usize); + chars.next(); + } + let mut prec = None; + if chars.peek() == Some(&'.') { + chars.next(); + let mut p = 0usize; + while let Some(d) = chars.peek().and_then(|d| d.to_digit(10)) { + p = p.saturating_mul(10).saturating_add(d as usize); + chars.next(); + } + prec = Some(p); + } + if width > MAX_PRINTF_WIDTH_PRECISION || prec.is_some_and(|p| p > MAX_PRINTF_WIDTH_PRECISION) { + return Err(error(format!("util.printf width or precision exceeds {MAX_PRINTF_WIDTH_PRECISION}"))); + } + let conv = chars.next().unwrap_or('s'); + let v = arg(args, next); + next += 1; + let body = match conv { + 'd' | 'f' => { + let n = v.to_number(ctx)?; + let p = if conv == 'd' { 0 } else { prec.unwrap_or(6) }; + let n = if conv == 'd' { n.trunc() } else { n }; + let mut t = group(&format!("{:.*}", p, n.abs()), sep); + if n < 0.0 { + t.insert(0, '-'); + } else if plus { + t.insert(0, '+'); + } else if space { + t.insert(0, ' '); + } + t + } + 'x' => format!("{:X}", v.to_number(ctx)? as i64), + _ => { + let t = text(&v, ctx)?; + match prec { + Some(p) => t.chars().take(p).collect(), + None => t, + } + } + }; + let pad = width.saturating_sub(body.chars().count()); + check_output(out.len().saturating_add(pad).saturating_add(body.len()))?; + if zero && matches!(conv, 'd' | 'f') { + let (sign, digits) = if body.starts_with(['-', '+', ' ']) { body.split_at(1) } else { ("", body.as_str()) }; + out.push_str(sign); + out.push_str(&"0".repeat(pad)); + out.push_str(digits); + } else { + out.push_str(&" ".repeat(pad)); + out.push_str(&body); + } + } + Ok(s(&out)) +} + +/// Group the integer part of a plain decimal number per `util.printf`'s `,n` flag. +fn group(t: &str, sep: u8) -> String { + let (int, frac) = t.split_once('.').map_or((t, None), |(a, b)| (a, Some(b))); + let (thousands, point) = match sep { + 0 => (Some(','), '.'), + 2 => (Some('.'), ','), + 3 => (None, ','), + _ => (None, '.'), + }; + let mut g = String::new(); + for (i, ch) in int.chars().enumerate() { + if let Some(th) = thousands + && i > 0 + && (int.len() - i).is_multiple_of(3) + { + g.push(th); + } + g.push(ch); + } + if let Some(f) = frac { + g.push(point); + g.push_str(f); + } + g +} + +const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; + +const DAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; + +/// `util.printd(cFormat, oDate)`: yyyy yy mmmm mmm mm m dddd ddd dd d HH H hh h MM M ss s tt, +/// and the numeric formats 0 (D:yyyymmddHHMMss), 1 (yyyy.mm.dd HH:MM:ss) and 2 (m/d/yy h:MM:ss tt). +pub(crate) fn printd(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let f = arg(args, 0); + let fmt = if f.is_number() { + match f.to_number(ctx)? as i32 { + 0 => "D:yyyymmddHHMMss".to_string(), + 1 => "yyyy.mm.dd HH:MM:ss".to_string(), + _ => "m/d/yy h:MM:ss tt".to_string(), + } + } else { + text(&f, ctx)? + }; + let d = arg(args, 1); + let Some(date) = d.as_object() else { return Err(error("util.printd needs a Date")) }; + let mut part = |m: &str| -> JsResult { + let func = date.get(JsString::from(m), ctx)?; + let func = func.as_callable().ok_or_else(|| error("util.printd needs a Date"))?; + let v = func.call(&d, &[], ctx)?.to_number(ctx)?; + if v.is_nan() { + return Err(error("invalid date")); + } + Ok(v as i64) + }; + let (y, mo, day, wd, h, mi, sec) = + (part("getFullYear")?, part("getMonth")?, part("getDate")?, part("getDay")?, part("getHours")?, part("getMinutes")?, part("getSeconds")?); + let mut out = String::new(); + let b = fmt.as_bytes(); + let mut i = 0; + while i < b.len() { + let c = b[i]; + // Only date letters read as runs; a literal is copied one character at a time, so + // measuring its run would rescan a long literal stretch at every position. + let run = if b"ymdHhMst".contains(&c) { b[i..].iter().take_while(|x| **x == c).count() } else { 1 }; + #[cfg(test)] + PRINTD_SCANNED.fetch_add(run as u64, std::sync::atomic::Ordering::Relaxed); + let push_num = |out: &mut String, v: i64, n: usize| { + if n >= 2 { + out.push_str(&format!("{v:02}")); + } else { + out.push_str(&v.to_string()); + } + }; + match c { + b'y' => { + if run >= 4 { + out.push_str(&format!("{y:04}")); + } else { + out.push_str(&format!("{:02}", y % 100)); + } + } + b'm' => match run { + r if r >= 4 => out.push_str(MONTHS[mo as usize % 12]), + 3 => out.push_str(&MONTHS[mo as usize % 12][..3]), + n => push_num(&mut out, mo + 1, n), + }, + b'd' => match run { + r if r >= 4 => out.push_str(DAYS[wd as usize % 7]), + 3 => out.push_str(&DAYS[wd as usize % 7][..3]), + n => push_num(&mut out, day, n), + }, + b'H' => push_num(&mut out, h, run), + b'h' => push_num(&mut out, if h % 12 == 0 { 12 } else { h % 12 }, run), + b'M' => push_num(&mut out, mi, run), + b's' => push_num(&mut out, sec, run), + b't' => { + let ampm = if h < 12 { "am" } else { "pm" }; + out.push_str(if run >= 2 { ampm } else { &m[..1] }); + } + b'\\' if i + 1 < b.len() => { + // The escaped character may be multi-byte; copy it whole. + let ch = fmt[i + 1..].chars().next().unwrap_or(' '); + out.push(ch); + i += 1 + ch.len_utf8(); + continue; + } + _ => { + // Copy the rest of a multi-byte character too. + let ch = fmt[i..].chars().next().unwrap_or(' '); + out.push(ch); + i += ch.len_utf8(); + continue; + } + } + i += run; + } + Ok(s(&out)) +} + +/// A field of a `util.scand` format. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum DateUnit { + /// `yyyy`: four digits. + Year, + /// `yy`: two digits, read as 1950–2049. + ShortYear, + Month, + /// `mmm`, `mmmm`: a month's name or its three-letter abbreviation. + MonthName, + Day, + /// `ddd`, `dddd`: a weekday's name, read and not checked. + Weekday, + Hour, + /// `h`, `hh`: 0–12, with `tt` for the afternoon. + Hour12, + Minute, + Second, + /// `t`, `tt`: am or pm. + AmPm, +} + +impl DateUnit { + /// A year, month or day: a format needs one of these for `util.scand` to read anything. + fn is_date(self) -> bool { + matches!(self, Self::Year | Self::ShortYear | Self::Month | Self::MonthName | Self::Day) + } + + /// Read from a run of digits, not from letters. + fn is_numeric(self) -> bool { + !matches!(self, Self::MonthName | Self::Weekday | Self::AmPm) + } +} + +/// A piece of a `util.scand` format. +#[derive(Debug, PartialEq, Eq)] +enum ScanPart { + /// A field, the digits it takes when typed together with the next one (`width`), and whether + /// the next one follows it with nothing between them in the format (`joined`). + Field { unit: DateUnit, width: usize, joined: bool }, + /// Letters the format writes as they are, which the text must have there. + Word(String), +} + +/// The fields and literal words of a `util.scand` format, in order. A numeric field is marked +/// `joined` when another numeric field follows it directly; anything but letters and digits +/// outside a field is left out. +fn scan_parts(fmt: &str) -> Vec { + let chars: Vec = fmt.chars().collect(); + let mut parts: Vec = Vec::new(); + // The last part is a field written right before this point, or letters still being collected. + let (mut after_field, mut in_word) = (false, false); + let mut i = 0; + while let Some(&c) = chars.get(i) { + // Counted for field letters only, so a long run of anything else is read once. + let run = if "ymdHhMst".contains(c) { chars.get(i..).unwrap_or_default().iter().take_while(|x| **x == c).count() } else { 1 }; + let field = match (c, run) { + ('y', 4..) => Some((DateUnit::Year, 4)), + ('y', _) => Some((DateUnit::ShortYear, 2)), + ('m', 3..) => Some((DateUnit::MonthName, 0)), + ('m', n) => Some((DateUnit::Month, n)), + ('d', 3..) => Some((DateUnit::Weekday, 0)), + ('d', n) => Some((DateUnit::Day, n)), + ('H', n) => Some((DateUnit::Hour, n.min(2))), + ('h', n) => Some((DateUnit::Hour12, n.min(2))), + ('M', n) => Some((DateUnit::Minute, n.min(2))), + ('s', n) => Some((DateUnit::Second, n.min(2))), + ('t', _) => Some((DateUnit::AmPm, 0)), + _ => None, + }; + if let Some((unit, width)) = field { + if after_field + && unit.is_numeric() + && let Some(ScanPart::Field { unit: last, joined, .. }) = parts.last_mut() + && last.is_numeric() + { + *joined = true; + } + parts.push(ScanPart::Field { unit, width, joined: false }); + (after_field, in_word) = (true, false); + i += run; + continue; + } + // `\` writes the next character as it is. + let (literal, step) = if c == '\\' { (chars.get(i + 1).copied(), 2) } else { (Some(c), 1) }; + i += step; + after_field = false; + match literal { + Some(l) if l.is_alphanumeric() => match parts.last_mut() { + Some(ScanPart::Word(w)) if in_word => w.push(l), + _ => { + parts.push(ScanPart::Word(l.to_string())); + in_word = true; + } + }, + _ => in_word = false, + } + } + parts +} + +/// A run of digits or of letters in the text `util.scand` reads. +enum ScanToken<'a> { + Digits(&'a str), + Letters(&'a str), +} + +/// The text's runs of digits and of letters, in order; anything else only separates them, and +/// may not begin or end the text. `None` when it does. +fn scan_tokens(text: &str) -> Option>> { + // Some(true) for a digit, Some(false) for a letter, None for a separator. + let kind = |c: char| (c.is_ascii_digit() || c.is_alphabetic()).then_some(c.is_ascii_digit()); + kind(text.chars().next()?)?; + kind(text.chars().next_back()?)?; + let mut out = Vec::new(); + let mut start: Option<(usize, bool)> = None; + for (at, c) in text.char_indices().chain(std::iter::once((text.len(), ' '))) { + let k = kind(c); + if let Some((from, digits)) = start + && k != Some(digits) + { + let piece = text.get(from..at)?; + out.push(if digits { ScanToken::Digits(piece) } else { ScanToken::Letters(piece) }); + start = None; + } + if let Some(digits) = k + && start.is_none() + { + start = Some((at, digits)); + } + } + Some(out) +} + +/// What `util.scand` reads. +#[derive(Debug, PartialEq, Eq)] +enum Scanned { + /// The text is empty: today, at noon. + Today, + /// A date and time: year, month (1–12), day, hours, minutes, seconds. + At(i32, u32, u32, u32, u32, u32), +} + +/// The fields `util.scand` has read so far. +#[derive(Default)] +struct ScanValues { + y: Option, + mo: Option, + d: Option, + h: Option, + mi: Option, + sec: Option, + pm: Option, + /// The hour was read from `h` or `hh`. + twelve: bool, +} + +impl ScanValues { + /// Records `v` as the value of `unit`: a `yy` year as 1950–2049, and an `h` or `hh` hour + /// marked as 12-hour. Month names, weekdays and am/pm are not read here. + fn set(&mut self, unit: DateUnit, v: u32) { + match unit { + DateUnit::Year => self.y = i32::try_from(v).ok(), + DateUnit::ShortYear => self.y = i32::try_from(v).ok().map(|v| if v < 50 { 2000 + v } else { 1900 + v }), + DateUnit::Month => self.mo = Some(v), + DateUnit::Day => self.d = Some(v), + DateUnit::Hour => self.h = Some(v), + DateUnit::Hour12 => { + self.h = Some(v); + self.twelve = true; + } + DateUnit::Minute => self.mi = Some(v), + DateUnit::Second => self.sec = Some(v), + DateUnit::MonthName | DateUnit::Weekday | DateUnit::AmPm => {} + } + } +} + +/// Reads `text` by a `util.scand` format, as another viewer was observed to (black-box): +/// +/// - Fields may be separated by anything but digits and letters, whether or not the format +/// separates them, but nothing may come before the first or after the last. +/// - Digits typed together fill the format's fields written together with exactly as many +/// digits as each has letters (`yyyymd`: 4, 1 and 1). A run of digits for one field alone may +/// have one or two, except a four-digit year. +/// - `yy` is 1950–2049; month names may be written in full or as three letters, in any case. +/// - Impossible dates are refused, but a month or day of 0 is read as 1. +/// - A format with no year, month or day reads nothing; an empty text is today, at noon. +/// +/// Not observed: a year left out is `this_year`, and a month or day left out is 1. +fn scan_date(fmt: &str, text: &str, this_year: i32) -> Option { + let parts = scan_parts(fmt); + if !parts.iter().any(|p| matches!(p, ScanPart::Field { unit, .. } if unit.is_date())) { + return None; + } + if text.is_empty() { + return Some(Scanned::Today); + } + let tokens = scan_tokens(text)?; + let mut v = ScanValues::default(); + let (mut p, mut t) = (0, 0); + while let Some(part) = parts.get(p) { + let token = tokens.get(t)?; + t += 1; + match (part, token) { + (ScanPart::Word(w), ScanToken::Letters(l) | ScanToken::Digits(l)) => { + if w.to_lowercase() != l.to_lowercase() { + return None; + } + p += 1; + } + (ScanPart::Field { unit: DateUnit::MonthName, .. }, ScanToken::Letters(l)) => { + v.mo = Some(named(&MONTHS, l)?.saturating_add(1)); + p += 1; + } + (ScanPart::Field { unit: DateUnit::Weekday, .. }, ScanToken::Letters(l)) => { + named(&DAYS, l)?; + p += 1; + } + (ScanPart::Field { unit: DateUnit::AmPm, .. }, ScanToken::Letters(l)) => { + v.pm = Some(match l.to_lowercase().as_str() { + "am" | "a" => false, + "pm" | "p" => true, + _ => return None, + }); + p += 1; + } + (ScanPart::Field { unit, width, joined }, ScanToken::Digits(run)) if unit.is_numeric() => { + // The fields written together from here, no more than the run's digits can fill, + // and the fewest of them the run fills. + let mut group = vec![(*unit, *width)]; + let (mut more, mut widths) = (*joined, *width); + while more + && widths < run.len() + && let Some(ScanPart::Field { unit, width, joined }) = parts.get(p + group.len()) + { + group.push((*unit, *width)); + widths = widths.saturating_add(*width); + more = *joined; + } + let mut sum = 0; + let fills = group.iter().position(|(_, w)| { + sum += w; + sum == run.len() + }); + if let Some(last) = fills { + let mut at = 0; + for (unit, w) in group.iter().take(last + 1) { + v.set(*unit, run.get(at..at + w)?.parse().ok()?); + at += w; + } + p += last + 1; + } else if *unit != DateUnit::Year && run.len() <= 2 { + v.set(*unit, run.parse().ok()?); + p += 1; + } else { + return None; + } + } + _ => return None, + } + } + if t != tokens.len() { + return None; + } + let y = v.y.unwrap_or(this_year); + let mo = v.mo.unwrap_or(1).max(1); + let d = v.d.unwrap_or(1).max(1); + if mo > 12 || i64::from(d) > i64::from(formcalc::days_in(i64::from(y), mo)) { + return None; + } + let mut h = v.h.unwrap_or(0); + if v.twelve && h > 12 { + return None; + } + match v.pm { + Some(true) if h < 12 => h += 12, + Some(false) if h == 12 => h = 0, + _ => {} + } + let (mi, sec) = (v.mi.unwrap_or(0), v.sec.unwrap_or(0)); + (h <= 23 && mi <= 59 && sec <= 59).then_some(Scanned::At(y, mo, d, h, mi, sec)) +} + +/// The index of the name `word` is, in full or as its first three letters, in any case. +fn named(names: &[&str], word: &str) -> Option { + let word = word.to_lowercase(); + let i = names.iter().position(|n| { + let n = n.to_lowercase(); + word == n || n.get(..3) == Some(word.as_str()) + })?; + u32::try_from(i).ok() +} + +/// `util.scand(cFormat, cDate)`: the date `cDate` gives by a `util.printd` format, as a local +/// `Date`, or `null` when it doesn't read as one (see [`scan_date`]). Only text is read. +pub(crate) fn scand(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let Some(typed) = arg(args, 1).as_string().map(|t| t.to_std_string_escaped()) else { + return Ok(JsValue::null()); + }; + let fmt = text(&arg(args, 0), ctx)?; + let date = JsDate::new(ctx); + let this_year = date.get_full_year(ctx)?.to_number(ctx)?; + let this_year = if this_year.is_finite() { this_year as i32 } else { 2000 }; + match scan_date(&fmt, &typed, this_year) { + None => return Ok(JsValue::null()), + Some(Scanned::Today) => { + date.set_hours(&[12.into(), 0.into(), 0.into(), 0.into()], ctx)?; + } + Some(Scanned::At(y, mo, d, h, mi, sec)) => { + // setFullYear, not the constructor, so years 0–99 stay as they are. + date.set_full_year(&[y.into(), (mo.saturating_sub(1)).into(), d.into()], ctx)?; + date.set_hours(&[h.into(), mi.into(), sec.into(), 0.into()], ctx)?; + } + } + Ok(date.into()) +} + +/// `util.printx(cFormat, cSource)`: `?` any character, `X` letter or digit, `A` letter, `9` +/// digit, `*` the rest, `\` escape, `>` / `<` / `=` upper / lower / as is. +pub(crate) fn printx(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let fmt: Vec = text(&arg(args, 0), ctx)?.chars().collect(); + let src: Vec = text(&arg(args, 1), ctx)?.chars().collect(); + let (mut out, mut j, mut case) = (String::new(), 0, 0); + let mut i = 0; + let push = |out: &mut String, c: char, case: i32| match case { + 1 => out.extend(c.to_uppercase()), + -1 => out.extend(c.to_lowercase()), + _ => out.push(c), + }; + while i < fmt.len() { + let f = fmt[i]; + i += 1; + let take = |j: &mut usize, ok: &dyn Fn(char) -> bool| -> Option { + while *j < src.len() { + let c = src[*j]; + *j += 1; + if ok(c) { + return Some(c); + } + } + None + }; + match f { + '?' => { + if let Some(c) = take(&mut j, &|_| true) { + push(&mut out, c, case) + } + } + 'X' => { + if let Some(c) = take(&mut j, &|c| c.is_alphanumeric()) { + push(&mut out, c, case) + } + } + 'A' => { + if let Some(c) = take(&mut j, &|c| c.is_alphabetic()) { + push(&mut out, c, case) + } + } + '9' => { + if let Some(c) = take(&mut j, &|c| c.is_ascii_digit()) { + push(&mut out, c, case) + } + } + '*' => { + for c in src[j.min(src.len())..].iter().copied() { + push(&mut out, c, case); + } + j = src.len(); + } + '\\' if i < fmt.len() => { + out.push(fmt[i]); + i += 1; + } + '>' => case = 1, + '<' => case = -1, + '=' => case = 0, + c => out.push(c), + } + } + Ok(s(&out)) +} + +// ── stack safety ──────────────────────────────────────────────────────────────────────────── +// +// boa's parser and compiler recurse once per nesting level and have no limit of their own: about +// a hundred nested parentheses overflow a 2 MiB stack, which aborts the whole app. Scripts come +// from documents, so `run` refuses nesting no real form needs and runs the engine on a thread +// with a large stack (address space, committed only as it is used). Together these bound the +// stack a script can use: brackets and prefix operators by the limits below, and every other +// construct (a few KiB per level, several bytes of source each) by the length limit. + +/// Bytes `util.printd` has scanned for runs of a format character, for tests that bound its work. +#[cfg(test)] +pub(crate) static PRINTD_SCANNED: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); diff --git a/crates/js/src/formcalc.rs b/crates/js/src/formcalc.rs index 39ecdbde5a..cfad619938 100644 --- a/crates/js/src/formcalc.rs +++ b/crates/js/src/formcalc.rs @@ -12,11 +12,32 @@ use std::collections::{HashMap, HashSet}; use std::rc::Rc; -use crate::Limits; use crate::xfa::{ HNode, XHost, XfaDoc, XfaEffect, XfaEvent, XfaKind, XfaNode, XfaOutcome, child_named, clip, clip_result, descendants_named, instances, reset_names, resolve_som, run_within, }; +use crate::{Limits, Message, MessageButtons, MessageIcon}; + +mod access; +mod ast; +mod builtins; +mod dates; +mod eval; +mod lexer; +mod parser; +mod value; +mod words; + +use ast::Stmt; +pub(crate) use dates::days_in; +#[cfg(test)] +use dates::{civil_from_days, days_from_civil, format_date, parse_date}; +use parser::parse; +use value::Val; +#[cfg(test)] +use value::{fixed, num_text, parse_num}; +#[cfg(test)] +use words::word_num; /// Deepest nesting the parser follows. const MAX_PARSE_DEPTH: usize = 64; @@ -47,722 +68,6 @@ fn is_limit(e: &str) -> bool { #[cfg(not(target_arch = "wasm32"))] const CLOCK_EVERY: u64 = 256; -// ── lexer ─────────────────────────────────────────────────────────────────────────────────── - -#[derive(Clone, Debug, PartialEq)] -enum Tok { - Num(f64), - Str(String), - Ident(String), - Op(&'static str), - Newline, - Eof, -} - -#[derive(Clone, Debug, PartialEq)] -struct Token { - tok: Tok, - line: usize, -} - -const OPS: [&str; 20] = ["<=", ">=", "==", "<>", "..", "(", ")", "[", "]", ".", ",", "=", "<", ">", "+", "-", "*", "/", "&", "|"]; - -fn lex(src: &str) -> Result, String> { - let chars: Vec = src.chars().collect(); - let mut out = Vec::new(); - let mut i = 0; - let mut line = 1; - let mut depth = 0usize; - while i < chars.len() { - let c = chars[i]; - match c { - '\n' => { - line += 1; - i += 1; - // Newlines inside brackets continue the expression. - if depth == 0 { - out.push(Token { tok: Tok::Newline, line }); - } - } - ' ' | '\t' | '\r' => i += 1, - ';' => { - while i < chars.len() && chars[i] != '\n' { - i += 1; - } - } - '/' if chars.get(i + 1) == Some(&'/') => { - while i < chars.len() && chars[i] != '\n' { - i += 1; - } - } - '"' => { - let mut s = String::new(); - i += 1; - loop { - match chars.get(i) { - None => return Err(format!("line {line}: unterminated string")), - Some('"') if chars.get(i + 1) == Some(&'"') => { - s.push('"'); - i += 2; - } - Some('"') => { - i += 1; - break; - } - Some(&ch) => { - if ch == '\n' { - line += 1; - } - s.push(ch); - i += 1; - } - } - if s.len() > MAX_STRING { - return Err(format!("line {line}: a string is too long")); - } - } - out.push(Token { tok: Tok::Str(s), line }); - } - c if c.is_ascii_digit() || (c == '.' && chars.get(i + 1).is_some_and(char::is_ascii_digit)) => { - let start = i; - while i < chars.len() && (chars[i].is_ascii_digit() || (chars[i] == '.' && chars.get(i + 1) != Some(&'.'))) { - i += 1; - } - if i < chars.len() && (chars[i] == 'e' || chars[i] == 'E') { - let mut j = i + 1; - if j < chars.len() && (chars[j] == '+' || chars[j] == '-') { - j += 1; - } - if j < chars.len() && chars[j].is_ascii_digit() { - i = j; - while i < chars.len() && chars[i].is_ascii_digit() { - i += 1; - } - } - } - let text: String = chars[start..i].iter().collect(); - let n: f64 = text.parse().map_err(|_| format!("line {line}: bad number {text}"))?; - out.push(Token { tok: Tok::Num(n), line }); - } - '!' => { - out.push(Token { tok: Tok::Ident("!".into()), line }); - i += 1; - } - c if c.is_alphabetic() || c == '_' || c == '$' => { - let start = i; - i += 1; - while i < chars.len() && (chars[i].is_alphanumeric() || chars[i] == '_' || chars[i] == '$') { - i += 1; - } - let text: String = chars[start..i].iter().collect(); - out.push(Token { tok: Tok::Ident(text), line }); - } - _ => { - let rest: String = chars[i..chars.len().min(i + 2)].iter().collect(); - let op = OPS.iter().find(|o| rest.starts_with(*o)).copied().ok_or_else(|| format!("line {line}: unexpected character {c:?}"))?; - match op { - "(" | "[" => depth += 1, - ")" | "]" => depth = depth.saturating_sub(1), - _ => {} - } - out.push(Token { tok: Tok::Op(op), line }); - i += op.len(); - } - } - } - out.push(Token { tok: Tok::Newline, line }); - out.push(Token { tok: Tok::Eof, line }); - Ok(out) -} - -// ── syntax ────────────────────────────────────────────────────────────────────────────────── - -#[derive(Clone, Debug, PartialEq)] -enum Expr { - Num(f64), - Str(String), - Null, - Ident(String), - /// `base.name` - Member(Box, String), - /// `base[n]`, `base[*]`, `base[-n]` - Index(Box, Idx), - /// `base..name` - Descend(Box, String), - Call(Box, Vec), - Neg(Box), - Not(Box), - Bin(&'static str, Box, Box), -} - -#[derive(Clone, Debug, PartialEq)] -enum Idx { - /// `[*]`: every instance. - All, - /// `[n]`: the n-th instance (from 0). - Abs(Box), - /// `[-n]` / `[+n]`: n instances before or after the one the script runs in. - Rel(i64), -} - -#[derive(Clone, Debug, PartialEq)] -enum Stmt { - Var(String, Option), - Assign(Expr, Expr), - Expr(Expr), - If(Vec<(Expr, Vec)>, Option>), - While(Expr, Vec), - For { var: String, from: Expr, to: Expr, up: bool, step: Option, body: Vec }, - Foreach { var: String, list: Vec, body: Vec }, - Func { name: String, f: Rc }, - Return(Option), - Break, - Continue, - Exit, - Throw(Expr), -} - -struct Parser { - toks: Vec, - at: usize, - depth: usize, - /// Operators and accessors chained so far in the expression being parsed. - chain: usize, -} - -fn kw(t: &Tok, word: &str) -> bool { - matches!(t, Tok::Ident(s) if s.eq_ignore_ascii_case(word)) -} - -impl Parser { - fn peek(&self) -> &Tok { - self.toks.get(self.at).map_or(&Tok::Eof, |t| &t.tok) - } - - fn line(&self) -> usize { - self.toks.get(self.at).map_or(0, |t| t.line) - } - - fn next(&mut self) -> Tok { - let t = self.peek().clone(); - self.at += 1; - t - } - - fn is_op(&self, op: &str) -> bool { - matches!(self.peek(), Tok::Op(o) if *o == op) - } - - fn eat_op(&mut self, op: &str) -> bool { - if self.is_op(op) { - self.at += 1; - true - } else { - false - } - } - - fn expect_op(&mut self, op: &str) -> Result<(), String> { - if self.eat_op(op) { Ok(()) } else { Err(format!("line {}: expected {op}", self.line())) } - } - - fn eat_kw(&mut self, word: &str) -> bool { - if kw(self.peek(), word) { - self.at += 1; - true - } else { - false - } - } - - fn expect_kw(&mut self, word: &str) -> Result<(), String> { - if self.eat_kw(word) { Ok(()) } else { Err(format!("line {}: expected {word}", self.line())) } - } - - fn skip_newlines(&mut self) { - while matches!(self.peek(), Tok::Newline) { - self.at += 1; - } - } - - fn enter(&mut self) -> Result<(), String> { - self.depth += 1; - if self.depth > MAX_PARSE_DEPTH { - return Err("the script is nested too deeply".into()); - } - Ok(()) - } - - fn leave(&mut self) { - self.depth = self.depth.saturating_sub(1); - } - - /// One more link in an operator or accessor chain. - fn link(&mut self) -> Result<(), String> { - self.chain += 1; - if self.chain > MAX_CHAIN { - return Err(format!("line {}: an expression is too long", self.line())); - } - Ok(()) - } - - /// Statements up to one of `enders` (not consumed). - fn block(&mut self, enders: &[&str]) -> Result, String> { - self.enter()?; - let mut out = Vec::new(); - loop { - self.skip_newlines(); - match self.peek() { - Tok::Eof => break, - Tok::Ident(w) if enders.iter().any(|e| w.eq_ignore_ascii_case(e)) => break, - _ => out.push(self.statement()?), - } - } - self.leave(); - Ok(out) - } - - fn statement(&mut self) -> Result { - let line = self.line(); - match self.peek().clone() { - Tok::Ident(w) if w.eq_ignore_ascii_case("var") => { - self.at += 1; - let Tok::Ident(name) = self.next() else { return Err(format!("line {line}: expected a name after var")) }; - let init = if self.eat_op("=") { Some(self.top_expr()?) } else { None }; - Ok(Stmt::Var(name, init)) - } - Tok::Ident(w) if w.eq_ignore_ascii_case("if") => { - self.at += 1; - let mut arms = Vec::new(); - let cond = self.top_expr()?; - self.expect_kw("then")?; - let body = self.block(&["elseif", "else", "endif"])?; - arms.push((cond, body)); - let mut otherwise = None; - loop { - if self.eat_kw("elseif") { - let c = self.top_expr()?; - self.expect_kw("then")?; - let b = self.block(&["elseif", "else", "endif"])?; - arms.push((c, b)); - } else if self.eat_kw("else") { - otherwise = Some(self.block(&["endif"])?); - } else { - self.expect_kw("endif")?; - break; - } - } - Ok(Stmt::If(arms, otherwise)) - } - Tok::Ident(w) if w.eq_ignore_ascii_case("while") => { - self.at += 1; - let cond = self.top_expr()?; - self.expect_kw("do")?; - let body = self.block(&["endwhile"])?; - self.expect_kw("endwhile")?; - Ok(Stmt::While(cond, body)) - } - Tok::Ident(w) if w.eq_ignore_ascii_case("for") => { - self.at += 1; - let Tok::Ident(var) = self.next() else { return Err(format!("line {line}: expected a variable after for")) }; - self.expect_op("=")?; - let from = self.top_expr()?; - let up = if self.eat_kw("upto") { - true - } else if self.eat_kw("downto") { - false - } else { - return Err(format!("line {line}: expected upto or downto")); - }; - let to = self.top_expr()?; - let step = if self.eat_kw("step") { Some(self.top_expr()?) } else { None }; - self.expect_kw("do")?; - let body = self.block(&["endfor"])?; - self.expect_kw("endfor")?; - Ok(Stmt::For { var, from, to, up, step, body }) - } - Tok::Ident(w) if w.eq_ignore_ascii_case("foreach") => { - self.at += 1; - let Tok::Ident(var) = self.next() else { return Err(format!("line {line}: expected a variable after foreach")) }; - self.expect_kw("in")?; - self.expect_op("(")?; - let mut list = Vec::new(); - if !self.is_op(")") { - loop { - list.push(self.top_expr()?); - if !self.eat_op(",") { - break; - } - } - } - self.expect_op(")")?; - self.expect_kw("do")?; - let body = self.block(&["endfor"])?; - self.expect_kw("endfor")?; - Ok(Stmt::Foreach { var, list, body }) - } - Tok::Ident(w) if w.eq_ignore_ascii_case("func") => { - self.at += 1; - let Tok::Ident(name) = self.next() else { return Err(format!("line {line}: expected a name after func")) }; - self.expect_op("(")?; - let mut params = Vec::new(); - if !self.is_op(")") { - loop { - let Tok::Ident(p) = self.next() else { return Err(format!("line {line}: expected a parameter name")) }; - params.push(p); - if !self.eat_op(",") { - break; - } - } - } - self.expect_op(")")?; - self.expect_kw("do")?; - let body = self.block(&["endfunc"])?; - self.expect_kw("endfunc")?; - Ok(Stmt::Func { name, f: Rc::new(Func { params, body }) }) - } - Tok::Ident(w) if w.eq_ignore_ascii_case("return") => { - self.at += 1; - let v = if matches!(self.peek(), Tok::Newline | Tok::Eof) { None } else { Some(self.top_expr()?) }; - Ok(Stmt::Return(v)) - } - Tok::Ident(w) if w.eq_ignore_ascii_case("break") => { - self.at += 1; - Ok(Stmt::Break) - } - Tok::Ident(w) if w.eq_ignore_ascii_case("continue") => { - self.at += 1; - Ok(Stmt::Continue) - } - Tok::Ident(w) if w.eq_ignore_ascii_case("exit") => { - self.at += 1; - Ok(Stmt::Exit) - } - Tok::Ident(w) if w.eq_ignore_ascii_case("throw") => { - self.at += 1; - Ok(Stmt::Throw(self.top_expr()?)) - } - Tok::Ident(w) if w.eq_ignore_ascii_case("do") => { - // `do … end`: a block as one statement. - self.at += 1; - let body = self.block(&["end"])?; - self.expect_kw("end")?; - Ok(Stmt::If(vec![(Expr::Num(1.0), body)], None)) - } - _ => { - let e = self.top_expr()?; - if self.eat_op("=") { - let v = self.top_expr()?; - return Ok(Stmt::Assign(e, v)); - } - Ok(Stmt::Expr(e)) - } - } - } - - /// An expression that starts a tree of its own (a condition, a bound, a statement's - /// expression): its operator chain is counted from zero, whatever came before it. - fn top_expr(&mut self) -> Result { - self.chain = 0; - self.expr() - } - - fn expr(&mut self) -> Result { - self.enter()?; - let e = self.or_expr(); - self.leave(); - e - } - - fn or_expr(&mut self) -> Result { - let mut l = self.and_expr()?; - loop { - if self.eat_op("|") || self.eat_kw("or") { - self.link()?; - let r = self.and_expr()?; - l = Expr::Bin("|", Box::new(l), Box::new(r)); - } else { - return Ok(l); - } - } - } - - fn and_expr(&mut self) -> Result { - let mut l = self.cmp_expr()?; - loop { - if self.eat_op("&") || self.eat_kw("and") { - self.link()?; - let r = self.cmp_expr()?; - l = Expr::Bin("&", Box::new(l), Box::new(r)); - } else { - return Ok(l); - } - } - } - - fn cmp_expr(&mut self) -> Result { - let mut l = self.add_expr()?; - loop { - let op = match self.peek() { - Tok::Op("==") => "==", - Tok::Op("<>") => "<>", - Tok::Op("<=") => "<=", - Tok::Op(">=") => ">=", - Tok::Op("<") => "<", - Tok::Op(">") => ">", - Tok::Ident(w) if w.eq_ignore_ascii_case("eq") => "==", - Tok::Ident(w) if w.eq_ignore_ascii_case("ne") => "<>", - Tok::Ident(w) if w.eq_ignore_ascii_case("le") => "<=", - Tok::Ident(w) if w.eq_ignore_ascii_case("ge") => ">=", - Tok::Ident(w) if w.eq_ignore_ascii_case("lt") => "<", - Tok::Ident(w) if w.eq_ignore_ascii_case("gt") => ">", - _ => return Ok(l), - }; - self.at += 1; - self.link()?; - let r = self.add_expr()?; - l = Expr::Bin(op, Box::new(l), Box::new(r)); - } - } - - fn add_expr(&mut self) -> Result { - let mut l = self.mul_expr()?; - loop { - let op = match self.peek() { - Tok::Op("+") => "+", - Tok::Op("-") => "-", - _ => return Ok(l), - }; - self.at += 1; - self.link()?; - let r = self.mul_expr()?; - l = Expr::Bin(op, Box::new(l), Box::new(r)); - } - } - - fn mul_expr(&mut self) -> Result { - let mut l = self.unary()?; - loop { - let op = match self.peek() { - Tok::Op("*") => "*", - Tok::Op("/") => "/", - _ => return Ok(l), - }; - self.at += 1; - self.link()?; - let r = self.unary()?; - l = Expr::Bin(op, Box::new(l), Box::new(r)); - } - } - - fn unary(&mut self) -> Result { - self.enter()?; - let e = if self.eat_op("-") { - Ok(Expr::Neg(Box::new(self.unary()?))) - } else if self.eat_op("+") { - self.unary() - } else if self.eat_kw("not") || matches!(self.peek(), Tok::Ident(w) if w == "!") { - if matches!(self.peek(), Tok::Ident(w) if w == "!") { - self.at += 1; - } - Ok(Expr::Not(Box::new(self.unary()?))) - } else { - self.postfix() - }; - self.leave(); - e - } - - fn postfix(&mut self) -> Result { - let mut e = self.primary()?; - loop { - if matches!(self.peek(), Tok::Op("." | ".." | "[" | "(")) { - self.link()?; - } - if self.eat_op(".") { - let Tok::Ident(name) = self.next() else { return Err(format!("line {}: expected a name after .", self.line())) }; - e = Expr::Member(Box::new(e), name); - } else if self.eat_op("..") { - let Tok::Ident(name) = self.next() else { return Err(format!("line {}: expected a name after ..", self.line())) }; - e = Expr::Descend(Box::new(e), name); - } else if self.eat_op("[") { - let sign = match self.peek() { - Tok::Op("-") => Some(-1), - Tok::Op("+") => Some(1), - _ => None, - }; - let relative = sign.and_then(|sign| match (self.toks.get(self.at + 1).map(|t| &t.tok), self.toks.get(self.at + 2).map(|t| &t.tok)) { - (Some(Tok::Num(n)), Some(Tok::Op("]"))) if n.is_finite() && *n >= 0.0 && *n < 1e6 => Some(sign * (*n as i64)), - _ => None, - }); - let idx = if let Some(k) = relative { - self.at += 2; - Idx::Rel(k) - } else if self.eat_op("*") { - Idx::All - } else { - Idx::Abs(Box::new(self.expr()?)) - }; - self.expect_op("]")?; - e = Expr::Index(Box::new(e), idx); - } else if self.eat_op("(") { - let mut args = Vec::new(); - self.skip_newlines(); - if !self.is_op(")") { - loop { - args.push(self.expr()?); - self.skip_newlines(); - if !self.eat_op(",") { - break; - } - self.skip_newlines(); - } - } - self.expect_op(")")?; - e = Expr::Call(Box::new(e), args); - } else { - return Ok(e); - } - } - } - - fn primary(&mut self) -> Result { - let line = self.line(); - match self.next() { - Tok::Num(n) => Ok(Expr::Num(n)), - Tok::Str(s) => Ok(Expr::Str(s)), - Tok::Op("(") => { - self.skip_newlines(); - let e = self.expr()?; - self.skip_newlines(); - self.expect_op(")")?; - Ok(e) - } - Tok::Ident(w) if w.eq_ignore_ascii_case("null") => Ok(Expr::Null), - Tok::Ident(w) if w.eq_ignore_ascii_case("nan") => Ok(Expr::Num(f64::NAN)), - Tok::Ident(w) if w.eq_ignore_ascii_case("infinity") => Ok(Expr::Num(f64::INFINITY)), - Tok::Ident(w) if w.eq_ignore_ascii_case("true") => Ok(Expr::Num(1.0)), - Tok::Ident(w) if w.eq_ignore_ascii_case("false") => Ok(Expr::Num(0.0)), - Tok::Ident(w) => Ok(Expr::Ident(w)), - t => Err(format!("line {line}: unexpected {}", describe(&t))), - } - } -} - -fn describe(t: &Tok) -> String { - match t { - Tok::Num(n) => format!("number {n}"), - Tok::Str(s) => format!("string {s:?}"), - Tok::Ident(s) => s.clone(), - Tok::Op(o) => format!("{o:?}"), - Tok::Newline => "end of line".into(), - Tok::Eof => "end of script".into(), - } -} - -fn parse(src: &str) -> Result, String> { - let toks = lex(src)?; - let mut p = Parser { toks, at: 0, depth: 0, chain: 0 }; - let body = p.block(&[])?; - match p.peek() { - Tok::Eof => Ok(body), - t => Err(format!("line {}: unexpected {}", p.line(), describe(t))), - } -} - -// ── values ────────────────────────────────────────────────────────────────────────────────── - -#[derive(Clone, Debug, PartialEq)] -enum Val { - Null, - Num(f64), - Str(String), - /// A form object, by index in the host. - Node(usize), - /// Several objects or values (`Row[*]`, `Row[*].Amount`). - List(Vec), - Host, - Layout, - Event, - /// `xfa` itself (`xfa.form`, `xfa.host`, …). - Xfa, - /// The instance manager of `name` under `parent`. - Im(usize, String), - /// A property object whose contents are not modelled (`$.border.fill…`): reads give - /// another sink, writes change nothing. - Sink, -} - -/// A number as FormCalc prints it: integers without a fraction, otherwise up to 15 digits. -fn num_text(n: f64) -> String { - if !n.is_finite() { - return if n.is_nan() { - "NaN".into() - } else if n > 0.0 { - "Infinity".into() - } else { - "-Infinity".into() - }; - } - if n.fract() == 0.0 && n.abs() < 1e15 { - return format!("{}", n as i64); - } - let s = format!("{n:.12}"); - let s = s.trim_end_matches('0').trim_end_matches('.'); - if s.is_empty() || s == "-0" { "0".into() } else { s.to_string() } -} - -/// `v` with `decimals` places, rounding exact halves away from zero (Rust's formatter rounds them to even). -fn fixed(v: f64, decimals: usize) -> String { - let k = 10f64.powi(decimals.min(20) as i32); - let scaled = (v * k).round() / k; - // A small negative value rounds to -0.0, which would print as "-0". - let scaled = if scaled == 0.0 { 0.0 } else { scaled }; - format!("{:.decimals$}", if scaled.is_finite() { scaled } else { v }) -} - -/// The number a string holds: its leading numeric part (`12abc` → 12), `None` for none. -fn parse_num(s: &str) -> Option { - let t = s.trim(); - if t.is_empty() { - return None; - } - let mut end = 0; - let b = t.as_bytes(); - let mut seen_digit = false; - let mut seen_dot = false; - let mut seen_e = false; - let mut i = 0; - if i < b.len() && (b[i] == b'-' || b[i] == b'+') { - i += 1; - } - while i < b.len() { - let c = b[i]; - if c.is_ascii_digit() { - seen_digit = true; - end = i + 1; - } else if c == b'.' && !seen_dot && !seen_e { - seen_dot = true; - } else if (c == b'e' || c == b'E') && seen_digit && !seen_e { - seen_e = true; - if i + 1 < b.len() && (b[i + 1] == b'-' || b[i + 1] == b'+') { - i += 1; - } - } else { - break; - } - i += 1; - } - if !seen_digit { - return None; - } - t.get(..end).and_then(|p| p.parse::().ok()).filter(|v| v.is_finite()) -} - -fn is_numeric_text(s: &str) -> bool { - s.trim().parse::().is_ok_and(f64::is_finite) -} - // ── the evaluator ─────────────────────────────────────────────────────────────────────────── enum Flow { @@ -799,1760 +104,6 @@ struct Interp<'h> { type R = Result; -impl Interp<'_> { - fn step(&mut self) -> R<()> { - self.charge(1) - } - - /// `n` units of work done (a step per statement or expression, more for work on long - /// strings and lists). - fn charge(&mut self, n: u64) -> R<()> { - let before = self.steps; - self.steps = self.steps.saturating_add(n); - if self.steps > MAX_STEPS { - return Err(RAN_TOO_LONG.into()); - } - #[cfg(not(target_arch = "wasm32"))] - if self.steps / CLOCK_EVERY != before / CLOCK_EVERY - && let Some(d) = self.deadline - && std::time::Instant::now() > d - { - return Err(RAN_TOO_LONG.into()); - } - #[cfg(target_arch = "wasm32")] - let _ = before; - Ok(()) - } - - /// `more` nodes into `out`, each once, never past [`MAX_LIST`]. - fn gather(&mut self, out: &mut Vec, seen: &mut HashSet, more: impl IntoIterator) -> R<()> { - for i in more { - if seen.insert(i) { - out.push(i); - if out.len() > MAX_LIST { - return Err(LIST_TOO_LONG.into()); - } - } - } - self.charge(1) - } - - /// The nodes of `v`, each once. - fn nodes_list(&self, v: &Val) -> Vec { - let mut seen = HashSet::new(); - self.nodes_of(v).into_iter().filter(|i| seen.insert(*i)).collect() - } - - fn node(&self, i: usize) -> Option<&HNode> { - self.h.nodes.get(i).filter(|n| !n.gone) - } - - // ── coercions - - /// A node's value as a FormCalc value. - fn node_value(&self, i: usize) -> Val { - let Some(n) = self.node(i) else { return Val::Null }; - if n.value.is_empty() { - return Val::Null; - } - if n.numeric - && let Some(v) = parse_num(&n.value) - { - return Val::Num(v); - } - Val::Str(n.value.clone()) - } - - /// What copying `v` (or the values it stands for) costs, in steps: one per 256 bytes, so - /// copying long strings over and over runs out of steps even where there is no clock - /// (wasm). A form object counts its value; a list its items too. - fn cost(&self, v: &Val) -> u64 { - let mut bytes = 0usize; - let mut todo = vec![v]; - while let Some(v) = todo.pop() { - match v { - Val::Str(s) => bytes = bytes.saturating_add(s.len()), - Val::Node(i) => bytes = bytes.saturating_add(self.node(*i).map_or(0, |n| n.value.len())), - Val::List(items) => { - bytes = bytes.saturating_add(items.len().saturating_mul(16)); - todo.extend(items.iter()); - } - _ => {} - } - } - (bytes / 256) as u64 - } - - /// [`Self::deref`], charged for the copy. - fn take(&mut self, v: Val) -> R { - self.charge(self.cost(&v))?; - Ok(self.deref(v)) - } - - /// Objects become their values; lists of objects become lists of values. - fn deref(&self, v: Val) -> Val { - match v { - Val::Node(i) => self.node_value(i), - Val::List(items) => Val::List(items.into_iter().map(|x| self.deref(x)).collect()), - other => other, - } - } - - fn to_num(&self, v: &Val) -> f64 { - match v { - Val::Null => 0.0, - Val::Num(n) => *n, - Val::Str(s) => parse_num(s).unwrap_or(0.0), - Val::Node(i) => self.to_num(&self.node_value(*i)), - Val::List(items) => items.first().map_or(0.0, |x| self.to_num(x)), - _ => 0.0, - } - } - - fn to_str(&self, v: &Val) -> String { - match v { - Val::Null => String::new(), - Val::Num(n) => num_text(*n), - Val::Str(s) => s.clone(), - Val::Node(i) => self.to_str(&self.node_value(*i)), - Val::List(items) => items.first().map_or(String::new(), |x| self.to_str(x)), - Val::Host => "host".into(), - Val::Layout => "layout".into(), - Val::Event => "event".into(), - Val::Xfa => "xfa".into(), - Val::Im(..) => "instanceManager".into(), - Val::Sink => String::new(), - } - } - - fn truthy(&self, v: &Val) -> bool { - match v { - Val::Null => false, - Val::Num(n) => *n != 0.0 && !n.is_nan(), - Val::Str(s) => parse_num(s).is_some_and(|n| n != 0.0), - Val::Node(i) => { - let d = self.node_value(*i); - self.truthy(&d) - } - Val::List(items) => !items.is_empty(), - _ => true, - } - } - - fn is_null(&self, v: &Val) -> bool { - match v { - Val::Null => true, - Val::Node(i) => self.node(*i).is_none_or(|n| n.value.is_empty()), - Val::List(items) => items.is_empty(), - _ => false, - } - } - - // ── scopes - - fn lookup(&self, name: &str) -> Option { - self.scopes.iter().rev().find_map(|s| s.get(name).cloned()) - } - - fn set_var(&mut self, name: &str, v: Val) -> bool { - for s in self.scopes.iter_mut().rev() { - if let Some(slot) = s.get_mut(name) { - *slot = v; - return true; - } - } - false - } - - fn declare(&mut self, name: &str, v: Val) { - if let Some(s) = self.scopes.last_mut() { - s.insert(name.to_string(), v); - } - } - - // ── accessors - - /// The objects an identifier names from the current node: `$`, the special objects, an - /// instance manager (`_name`), a variable, or a form object by name (children first, then - /// the containing subforms). - fn ident(&self, name: &str) -> R { - if let Some(v) = self.lookup(name) { - return Ok(v); - } - let lower = name.to_ascii_lowercase(); - Ok(match lower.as_str() { - "$" | "this" => Val::Node(self.h.current), - "$host" => Val::Host, - "$layout" => Val::Layout, - "$event" => Val::Event, - "$form" | "$data" | "$dataset" | "$datasets" => Val::Node(0), - "$record" => Val::Node(if self.node(1).is_some() { 1 } else { 0 }), - "$template" | "$xfa" => Val::Xfa, - "xfa" => Val::Xfa, - _ => { - if let Some(child) = name.strip_prefix('_') { - // The instance manager of `child`, looked for like a name. - let found = resolve_som(self.h, self.h.current, child); - let Some(&first) = found.first() else { return Err(format!("unknown instance manager _{child}")) }; - let parent = self.node(first).and_then(|n| n.parent).ok_or_else(|| format!("unknown instance manager _{child}"))?; - return Ok(Val::Im(parent, child.to_string())); - } - let found = resolve_som(self.h, self.h.current, name); - match found.as_slice() { - [] => return Err(format!("unknown name {name}")), - [one] => Val::Node(*one), - many => Val::List(many.iter().map(|i| Val::Node(*i)).collect()), - } - } - }) - } - - /// Every object a value stands for. - fn nodes_of(&self, v: &Val) -> Vec { - match v { - Val::Node(i) => vec![*i], - Val::List(items) => items.iter().flat_map(|x| self.nodes_of(x)).collect(), - _ => Vec::new(), - } - } - - fn member(&mut self, base: Val, name: &str) -> R { - match base { - Val::Node(i) => self.node_member(i, name), - Val::List(items) => { - let mut out = Vec::new(); - let mut seen = HashSet::new(); - for it in items { - match self.member(it, name)? { - Val::List(more) => { - for m in more { - self.list_push(&mut out, &mut seen, m)?; - } - } - Val::Null => {} - v => self.list_push(&mut out, &mut seen, v)?, - } - } - Ok(match out.len() { - 0 => Val::Null, - 1 => out.remove(0), - _ => Val::List(out), - }) - } - Val::Host => Ok(match name.to_ascii_lowercase().as_str() { - "numpages" => Val::Num(self.h.doc.page_count as f64), - "currentpage" => Val::Num(self.h.doc.page as f64), - "name" => Val::Str("Acrobat".into()), - "apptype" => Val::Str("Exchange-Pro".into()), - "version" => Val::Str("11".into()), - "variation" => Val::Str("Full".into()), - "language" => Val::Str("en".into()), - "platform" => Val::Str(crate::platform().into()), - "title" => Val::Str(self.h.doc.file_name.clone()), - "calculationsenabled" | "validationsenabled" => Val::Num(1.0), - _ => Val::Sink, - }), - Val::Layout => Ok(Val::Sink), - Val::Event => Ok(match name.to_ascii_lowercase().as_str() { - "name" => Val::Str(self.event.activity.clone()), - "newtext" | "fulltext" => Val::Str(self.event.new_text.clone()), - "prevtext" => Val::Str(self.event.prev_text.clone()), - "change" => Val::Str(String::new()), - "target" => Val::Node(self.h.current), - "selstart" | "selend" | "cancelaction" | "reenter" | "shift" | "modifier" | "keydown" | "commitkey" => Val::Num(0.0), - _ => Val::Sink, - }), - Val::Xfa => Ok(match name.to_ascii_lowercase().as_str() { - "form" | "datasets" | "data" => Val::Node(0), - "record" => Val::Node(if self.node(1).is_some() { 1 } else { 0 }), - "host" => Val::Host, - "layout" => Val::Layout, - "event" => Val::Event, - _ => Val::Sink, - }), - Val::Im(parent, child) => Ok(match name.to_ascii_lowercase().as_str() { - "count" => Val::Num(instances(self.h, parent, &child).len() as f64), - "min" => Val::Num(instances(self.h, parent, &child).first().and_then(|i| self.node(*i)).map_or(0.0, |n| n.occur_min as f64)), - "max" => Val::Num( - instances(self.h, parent, &child).first().and_then(|i| self.node(*i)).and_then(|n| n.occur_max).map_or(-1.0, |m| m as f64), - ), - "classname" => Val::Str("instanceManager".into()), - _ => Val::Sink, - }), - Val::Sink => Ok(Val::Sink), - Val::Null => Err(format!("no object to take {name} from")), - Val::Num(_) | Val::Str(_) => Err(format!("{} has no property {name}", self.to_str(&base))), - } - } - - /// `v` onto `out`: a node only once, never past [`MAX_LIST`]. - fn list_push(&mut self, out: &mut Vec, seen: &mut HashSet, v: Val) -> R<()> { - if let Val::Node(i) = v - && !seen.insert(i) - { - return Ok(()); - } - out.push(v); - if out.len() > MAX_LIST { - return Err(LIST_TOO_LONG.into()); - } - self.charge(1) - } - - /// Property `name` of form object `i` as FormCalc reads it: its value, attributes, - /// relatives and children by name. - fn node_member(&mut self, i: usize, name: &str) -> R { - let Some(n) = self.node(i) else { return Ok(Val::Null) }; - Ok(match name { - "formattedValue" => match &n.formatted { - Some(f) => Val::Str(f.clone()), - None => self.node_value(i), - }, - "rawValue" | "value" => self.node_value(i), - "name" => Val::Str(n.name.clone()), - "somExpression" => Val::Str(n.som.clone()), - "className" => Val::Str(n.kind.class_name().into()), - "index" | "instanceIndex" => Val::Num(n.index as f64), - "presence" => Val::Str(n.presence.clone()), - "access" => Val::Str(n.access.clone()), - "isNull" => Val::Num(if n.value.is_empty() { 1.0 } else { 0.0 }), - "mandatory" => Val::Str("disabled".into()), - "parent" => n.parent.map_or(Val::Null, Val::Node), - "nodes" => Val::List(n.children.iter().copied().filter(|c| self.node(*c).is_some()).map(Val::Node).collect()), - "all" => match n.parent { - Some(p) => Val::List(instances(self.h, p, &n.name).into_iter().map(Val::Node).collect()), - None => Val::Node(i), - }, - "instanceManager" => match n.parent { - Some(p) => Val::Im(p, n.name.clone()), - None => Val::Null, - }, - "border" | "ui" | "font" | "caption" | "fillColor" | "borderColor" | "fontColor" | "margin" | "para" | "assist" | "validate" - | "format" | "items" | "bind" | "keep" | "h" | "w" | "x" | "y" | "minH" | "maxH" | "minW" | "maxW" | "layout" | "model" - | "oneOfChild" | "locale" | "relevant" | "colSpan" | "dataNode" | "defaultValue" | "editValue" | "selectedIndex" | "event" - | "calculate" | "traversal" | "extras" | "desc" => Val::Sink, - other => { - if let Some(child) = other.strip_prefix('_') { - return Ok(if child_named(self.h, i, child).is_some() { Val::Im(i, child.to_string()) } else { Val::Null }); - } - let found = instances(self.h, i, other); - match found.as_slice() { - [] => Val::Null, - [one] => Val::Node(*one), - many => Val::List(many.iter().map(|k| Val::Node(*k)).collect()), - } - } - }) - } - - /// `base[n]` / `base[*]`: all the instances the last step named, then one or all. - fn index(&mut self, base: &Expr, idx: &Idx) -> R { - let all = self.all_of(base)?; - match idx { - Idx::All => Ok(Val::List(all.into_iter().map(Val::Node).collect())), - Idx::Abs(e) => { - let v = self.eval(e)?; - let n = self.to_num(&v); - if !n.is_finite() || n < 0.0 { - return Ok(Val::Null); - } - Ok(all.get(n as usize).map_or(Val::Null, |i| Val::Node(*i))) - } - Idx::Rel(k) => { - // From the instance the script runs in (`row[-1]` in a row is the row above), - // else from the first. - let here = all.iter().position(|&i| self.encloses(i, self.h.current)).unwrap_or(0); - let at = (here as i64).saturating_add(*k); - Ok(usize::try_from(at).ok().and_then(|at| all.get(at)).map_or(Val::Null, |i| Val::Node(*i))) - } - } - } - - /// Whether `node` is `inner` or one of its containers. - fn encloses(&self, node: usize, inner: usize) -> bool { - let mut at = Some(inner); - let mut guard = 0; - while let Some(i) = at - && guard < MAX_PARSE_DEPTH - { - if i == node { - return true; - } - at = self.h.nodes.get(i).and_then(|n| n.parent); - guard += 1; - } - false - } - - /// Every instance an accessor names (`Row` → all rows, not just the first), each once. - fn all_of(&mut self, e: &Expr) -> R> { - let mut out = Vec::new(); - let mut seen = HashSet::new(); - match e { - Expr::Ident(name) => { - if self.lookup(name).is_some() || name.starts_with('$') || name.eq_ignore_ascii_case("xfa") { - let v = self.eval(e)?; - return Ok(self.nodes_list(&v)); - } - // The name resolves to its first instance; `row[*]` means every sibling of it. - for first in resolve_som(self.h, self.h.current, name) { - match self.node(first).and_then(|n| n.parent.map(|p| (p, n.name.clone()))) { - Some((p, n)) => self.gather(&mut out, &mut seen, instances(self.h, p, &n))?, - None => self.gather(&mut out, &mut seen, [first])?, - } - } - } - Expr::Member(base, name) => { - let b = self.eval(base)?; - for i in self.nodes_list(&b) { - self.gather(&mut out, &mut seen, instances(self.h, i, name))?; - } - if out.is_empty() { - let v = self.member(b, name)?; - out = self.nodes_list(&v); - } - } - Expr::Descend(base, name) => { - let b = self.eval(base)?; - for i in self.nodes_list(&b) { - self.gather(&mut out, &mut seen, descendants_named(self.h, i, name, 0))?; - } - } - other => { - let v = self.eval(other)?; - out = self.nodes_list(&v); - } - } - Ok(out) - } - - // ── expressions - - fn eval(&mut self, e: &Expr) -> R { - self.step()?; - self.depth += 1; - if self.depth > self.max_depth { - self.depth -= 1; - return Err(TOO_DEEP.into()); - } - let r = self.eval_inner(e); - self.depth -= 1; - r - } - - fn eval_inner(&mut self, e: &Expr) -> R { - Ok(match e { - Expr::Num(n) => Val::Num(*n), - Expr::Str(s) => { - self.charge((s.len() / 256) as u64)?; - Val::Str(s.clone()) - } - Expr::Null => Val::Null, - Expr::Ident(name) => { - let v = self.ident(name)?; - self.charge(self.cost(&v))?; - v - } - Expr::Member(base, name) => { - let b = self.eval(base)?; - self.member(b, name)? - } - Expr::Index(base, idx) => self.index(base, idx)?, - Expr::Descend(base, name) => { - let b = self.eval(base)?; - let mut out = Vec::new(); - let mut seen = HashSet::new(); - for i in self.nodes_list(&b) { - self.gather(&mut out, &mut seen, descendants_named(self.h, i, name, 0))?; - } - match out.as_slice() { - [] => Val::Null, - [one] => Val::Node(*one), - many => Val::List(many.iter().map(|k| Val::Node(*k)).collect()), - } - } - Expr::Call(callee, args) => self.call(callee, args)?, - Expr::Neg(x) => { - let v = self.eval(x)?; - if self.is_null(&v) { Val::Null } else { Val::Num(-self.to_num(&v)) } - } - Expr::Not(x) => { - let v = self.eval(x)?; - Val::Num(if self.truthy(&v) { 0.0 } else { 1.0 }) - } - Expr::Bin(..) => { - // The parser leans left (`a + b + c` is `(a + b) + c`), so a chain is walked - // from its leftmost operand and folded without recursing per operator. - let mut links = Vec::new(); - let mut leftmost = e; - while let Expr::Bin(op, l, r) = leftmost { - links.push((*op, r.as_ref())); - leftmost = l; - } - let mut acc = self.eval(leftmost)?; - for (op, r) in links.into_iter().rev() { - self.step()?; - acc = match op { - "&" => { - if !self.truthy(&acc) { - // Not evaluated: the result is settled. - Val::Num(0.0) - } else { - let b = self.eval(r)?; - Val::Num(if self.truthy(&b) { 1.0 } else { 0.0 }) - } - } - "|" => { - if self.truthy(&acc) { - Val::Num(1.0) - } else { - let b = self.eval(r)?; - Val::Num(if self.truthy(&b) { 1.0 } else { 0.0 }) - } - } - _ => { - let b = self.eval(r)?; - // Comparing (and dereferencing) long strings is work too. - self.charge(self.cost(&acc).saturating_add(self.cost(&b)))?; - self.binary(op, acc, b)? - } - }; - } - acc - } - }) - } - - fn binary(&self, op: &str, a: Val, b: Val) -> R { - let (a, b) = (self.deref(a), self.deref(b)); - // Null with an arithmetic operator is null only when both sides are null. - match op { - "+" | "-" | "*" | "/" => { - if self.is_null(&a) && self.is_null(&b) { - return Ok(Val::Null); - } - let (x, y) = (self.to_num(&a), self.to_num(&b)); - let v = match op { - "+" => x + y, - "-" => x - y, - "*" => x * y, - _ => { - if y == 0.0 { - return Err("division by zero".into()); - } - x / y - } - }; - Ok(Val::Num(v)) - } - _ => { - let ord = match (&a, &b) { - (Val::Null, Val::Null) => Some(std::cmp::Ordering::Equal), - (Val::Null, _) | (_, Val::Null) => None, - (Val::Str(s), Val::Str(t)) if !(is_numeric_text(s) && is_numeric_text(t)) => Some(s.cmp(t)), - (Val::Str(s), Val::Num(_)) if !is_numeric_text(s) => Some(s.as_str().cmp(num_text(self.to_num(&b)).as_str())), - (Val::Num(_), Val::Str(t)) if !is_numeric_text(t) => Some(num_text(self.to_num(&a)).as_str().cmp(t.as_str())), - _ => self.to_num(&a).partial_cmp(&self.to_num(&b)), - }; - let r = match (op, ord) { - ("==", Some(o)) => o.is_eq(), - ("<>", Some(o)) => !o.is_eq(), - ("<>", None) => true, - ("<", Some(o)) => o.is_lt(), - ("<=", Some(o)) => o.is_le(), - (">", Some(o)) => o.is_gt(), - (">=", Some(o)) => o.is_ge(), - _ => false, - }; - Ok(Val::Num(if r { 1.0 } else { 0.0 })) - } - } - } - - // ── calls - - fn call(&mut self, callee: &Expr, args: &[Expr]) -> R { - match callee { - Expr::Ident(name) => { - if let Some(f) = self.funcs.get(&name.to_ascii_lowercase()).cloned() { - let mut vals = Vec::new(); - for a in args { - let v = self.eval(a)?; - vals.push(self.take(v)?); - } - return self.call_user(&f, vals); - } - self.builtin(name, args) - } - Expr::Member(base, method) => { - let b = self.eval(base)?; - self.method(b, method, args) - } - _ => Err("not a function".into()), - } - } - - fn call_user(&mut self, f: &Func, vals: Vec) -> R { - if self.scopes.len() > self.max_depth { - return Err(CALLS_TOO_DEEP.into()); - } - let mut scope = HashMap::new(); - for (i, p) in f.params.iter().enumerate() { - scope.insert(p.clone(), vals.get(i).cloned().unwrap_or(Val::Null)); - } - self.scopes.push(scope); - let r = self.block(&f.body); - self.scopes.pop(); - match r? { - (Flow::Return(v), _) => Ok(v), - (Flow::Exit, last) => { - self.exited = true; - Ok(last) - } - (_, last) => Ok(last), - } - } - - fn arg(&mut self, args: &[Expr], i: usize) -> R { - match args.get(i) { - Some(e) => { - let v = self.eval(e)?; - self.take(v) - } - None => Ok(Val::Null), - } - } - - fn arg_num(&mut self, args: &[Expr], i: usize) -> R { - let v = self.arg(args, i)?; - Ok(self.to_num(&v)) - } - - fn arg_str(&mut self, args: &[Expr], i: usize) -> R { - let v = self.arg(args, i)?; - Ok(self.to_str(&v)) - } - - /// Every value the arguments hold, lists flattened, nulls left out. - fn arg_values(&mut self, args: &[Expr]) -> R> { - self.arg_values_keeping(args, false) - } - - /// [`Self::arg_values`]; `objects` keeps form objects (fields, subforms) as themselves, so - /// `Count(row[*])` counts rows rather than filled-in values. - fn arg_values_keeping(&mut self, args: &[Expr], objects: bool) -> R> { - let mut out = Vec::new(); - for a in args { - let v = self.eval(a)?; - let v = if objects { v } else { self.take(v)? }; - match v { - Val::List(items) => out.extend(items.into_iter().filter(|x| !matches!(x, Val::Null))), - Val::Null => {} - other => out.push(other), - } - if out.len() > MAX_LIST { - return Err(LIST_TOO_LONG.into()); - } - } - Ok(out) - } - - fn method(&mut self, base: Val, method: &str, args: &[Expr]) -> R { - let m = method.to_ascii_lowercase(); - match base { - Val::Host => match m.as_str() { - "messagebox" => { - let t = self.arg_str(args, 0)?; - self.h.push(XfaEffect::MessageBox(t)); - Ok(Val::Num(1.0)) - } - "resetdata" => { - let list = self.arg_str(args, 0)?; - self.h.push(XfaEffect::ResetData(reset_names(&list))); - Ok(Val::Null) - } - "print" => { - self.h.push(XfaEffect::Print); - Ok(Val::Null) - } - "beep" => { - self.h.push(XfaEffect::Beep); - Ok(Val::Null) - } - "gotourl" => { - let u = self.arg_str(args, 0)?; - self.h.push(XfaEffect::LaunchUrl(u)); - Ok(Val::Null) - } - "setfocus" => { - let target = match args.first() { - Some(e) => { - let raw = self.eval(e)?; - match self.nodes_of(&raw).first() { - Some(i) => self.node(*i).map(|n| n.som.clone()).unwrap_or_default(), - None => { - let s = self.to_str(&raw); - let found = resolve_som(self.h, self.h.current, &s); - found.first().and_then(|i| self.node(*i)).map(|n| n.som.clone()).unwrap_or(s) - } - } - } - None => String::new(), - }; - self.h.push(XfaEffect::SetFocus(target)); - Ok(Val::Null) - } - _ => Ok(Val::Null), - }, - Val::Layout => match m.as_str() { - "page" | "abspage" => Ok(Val::Num(self.h.doc.page as f64 + if m == "page" { 1.0 } else { 0.0 })), - "pagecount" | "abspagecount" => Ok(Val::Num(self.h.doc.page_count as f64)), - "relayout" | "relayoutpagearea" => { - self.h.push(XfaEffect::Relayout); - Ok(Val::Null) - } - _ => Ok(Val::Num(0.0)), - }, - Val::Xfa => match m.as_str() { - "resolvenode" | "resolvenodes" => { - let s = self.arg_str(args, 0)?; - let found = resolve_som(self.h, self.h.current, &s); - Ok(self.set_val(found, m == "resolvenodes")) - } - "recalculate" => { - self.h.push(XfaEffect::Recalculate); - Ok(Val::Null) - } - "remerge" => { - self.h.push(XfaEffect::Relayout); - Ok(Val::Null) - } - _ => Ok(Val::Null), - }, - Val::Node(i) => match m.as_str() { - "resolvenode" | "resolvenodes" => { - let s = self.arg_str(args, 0)?; - let found = resolve_som(self.h, i, &s); - Ok(self.set_val(found, m == "resolvenodes")) - } - "recalculate" => { - self.h.push(XfaEffect::Recalculate); - Ok(Val::Null) - } - "remerge" => { - self.h.push(XfaEffect::Relayout); - Ok(Val::Null) - } - _ => Ok(Val::Null), - }, - Val::Im(parent, name) => match m.as_str() { - "addinstance" | "insertinstance" => Ok(self.h.add_instance(parent, &name).map_or(Val::Null, Val::Node)), - "removeinstance" => { - let i = self.arg_num(args, 0)?; - if i.is_finite() && i >= 0.0 { - self.h.remove_instance(parent, &name, i as usize); - } - Ok(Val::Null) - } - "setinstances" => { - let want = self.arg_num(args, 0)?; - if want.is_finite() && want >= 0.0 { - self.h.set_instances(parent, &name, want as usize); - } - Ok(Val::Null) - } - _ => Ok(Val::Null), - }, - Val::Event | Val::Sink | Val::List(_) => Ok(Val::Null), - Val::Null => Err(format!("no object to call {method} on")), - Val::Num(_) | Val::Str(_) => Err(format!("{} has no method {method}", self.to_str(&base))), - } - } - - fn set_val(&self, found: Vec, many: bool) -> Val { - if many { - return Val::List(found.into_iter().map(Val::Node).collect()); - } - match found.as_slice() { - [] => Val::Null, - [one] => Val::Node(*one), - all => Val::List(all.iter().map(|i| Val::Node(*i)).collect()), - } - } - - // ── assignment - - fn assign(&mut self, target: &Expr, v: Val) -> R<()> { - let v = self.deref(v); - match target { - Expr::Ident(name) => { - if self.set_var(name, v.clone()) { - return Ok(()); - } - let t = self.ident(name)?; - self.assign_to(t, "rawValue", v) - } - Expr::Member(base, prop) => { - let b = self.eval(base)?; - self.assign_to(b, prop, v) - } - Expr::Index(..) | Expr::Descend(..) => { - let t = self.eval(target)?; - self.assign_to(t, "rawValue", v) - } - _ => Err("cannot assign to that".into()), - } - } - - fn assign_to(&mut self, target: Val, prop: &str, v: Val) -> R<()> { - match target { - Val::Node(i) => { - let Some(n) = self.node(i) else { return Ok(()) }; - let som = n.som.clone(); - match prop { - "rawValue" | "value" | "formattedValue" => { - let text = self.to_str(&v); - if !self.h.set_value(i, text) { - return Err(format!("{} is not a field", if som.is_empty() { "the form" } else { som.as_str() })); - } - } - "presence" => { - let p = self.to_str(&v); - if matches!(p.as_str(), "visible" | "invisible" | "hidden" | "inactive") { - self.h.set_state(i, XfaEffect::SetPresence { som, presence: p }); - } - } - "access" => { - let a = self.to_str(&v); - if matches!(a.as_str(), "open" | "readOnly" | "protected" | "nonInteractive") { - self.h.set_state(i, XfaEffect::SetAccess { som, access: a }); - } - } - other => { - // `page1.qty = 7`: the child object named by the last step takes the value. - if let Some(child) = other.strip_prefix('_') { - if child_named(self.h, i, child).is_some() { - return self.assign_to(Val::Im(i, child.to_string()), "count", v); - } - return Ok(()); - } - let kids = instances(self.h, i, other); - if kids.is_empty() { - return Err(format!("{som} has no {other} to assign to")); - } - for k in kids { - // Every destination copies the value: charge each copy. - self.charge(self.cost(&v))?; - self.assign_to(Val::Node(k), "rawValue", v.clone())?; - } - } - } - Ok(()) - } - Val::List(items) => { - for it in items { - self.charge(self.cost(&v))?; - self.assign_to(it, prop, v.clone())?; - } - Ok(()) - } - Val::Im(parent, name) if prop.eq_ignore_ascii_case("count") => { - let want = self.to_num(&v); - if want.is_finite() && want >= 0.0 { - self.h.set_instances(parent, &name, want as usize); - } - Ok(()) - } - _ => Ok(()), - } - } - - // ── statements - - /// Run a block; returns how it ended and the value of its last expression statement. - fn block(&mut self, body: &[Stmt]) -> R<(Flow, Val)> { - // Nesting of statements counts like nesting of expressions: both are stack. - self.depth += 1; - if self.depth > self.max_depth { - self.depth -= 1; - return Err(TOO_DEEP.into()); - } - let r = self.block_inner(body); - self.depth -= 1; - r - } - - fn block_inner(&mut self, body: &[Stmt]) -> R<(Flow, Val)> { - let mut last = Val::Null; - for s in body { - self.step()?; - match self.exec(s)? { - (Flow::Next, v) => { - if let Some(v) = v { - last = v; - } - if self.exited { - return Ok((Flow::Exit, last)); - } - } - (flow, _) => return Ok((flow, last)), - } - } - Ok((Flow::Next, last)) - } - - fn exec(&mut self, s: &Stmt) -> R<(Flow, Option)> { - match s { - Stmt::Var(name, init) => { - let v = match init { - Some(e) => { - let v = self.eval(e)?; - self.take(v)? - } - None => Val::Null, - }; - self.declare(name, v.clone()); - Ok((Flow::Next, Some(v))) - } - Stmt::Assign(target, e) => { - let v = self.eval(e)?; - let v = self.take(v)?; - self.assign(target, v.clone())?; - Ok((Flow::Next, Some(v))) - } - Stmt::Expr(e) => { - let v = self.eval(e)?; - let v = self.take(v)?; - Ok((Flow::Next, Some(v))) - } - Stmt::If(arms, otherwise) => { - for (cond, body) in arms { - let c = self.eval(cond)?; - if self.truthy(&c) { - let (f, v) = self.block(body)?; - return Ok((f, Some(v))); - } - } - if let Some(body) = otherwise { - let (f, v) = self.block(body)?; - return Ok((f, Some(v))); - } - Ok((Flow::Next, None)) - } - Stmt::While(cond, body) => { - loop { - let c = self.eval(cond)?; - if !self.truthy(&c) { - break; - } - self.iterate()?; - match self.block(body)? { - (Flow::Break, _) => break, - (Flow::Return(v), _) => return Ok((Flow::Return(v), None)), - (Flow::Exit, _) => return Ok((Flow::Exit, None)), - _ => {} - } - } - Ok((Flow::Next, None)) - } - Stmt::For { var, from, to, up, step, body } => { - let start = { - let v = self.eval(from)?; - self.to_num(&v) - }; - let end = { - let v = self.eval(to)?; - self.to_num(&v) - }; - let by = match step { - Some(e) => { - let v = self.eval(e)?; - self.to_num(&v).abs() - } - None => 1.0, - }; - if by.is_nan() || by <= 0.0 || !start.is_finite() || !end.is_finite() { - return Err("bad for loop bounds".into()); - } - self.declare(var, Val::Num(start)); - let mut i = start; - while if *up { i <= end } else { i >= end } { - self.iterate()?; - self.set_var(var, Val::Num(i)); - match self.block(body)? { - (Flow::Break, _) => break, - (Flow::Return(v), _) => return Ok((Flow::Return(v), None)), - (Flow::Exit, _) => return Ok((Flow::Exit, None)), - _ => {} - } - i = if *up { i + by } else { i - by }; - } - Ok((Flow::Next, None)) - } - Stmt::Foreach { var, list, body } => { - let items = self.arg_values_keeping(list, true)?; - self.declare(var, Val::Null); - for it in items { - self.iterate()?; - self.set_var(var, it); - match self.block(body)? { - (Flow::Break, _) => break, - (Flow::Return(v), _) => return Ok((Flow::Return(v), None)), - (Flow::Exit, _) => return Ok((Flow::Exit, None)), - _ => {} - } - } - Ok((Flow::Next, None)) - } - Stmt::Func { name, f } => { - self.funcs.insert(name.to_ascii_lowercase(), Rc::clone(f)); - Ok((Flow::Next, None)) - } - Stmt::Return(e) => { - let v = match e { - Some(e) => { - let v = self.eval(e)?; - self.take(v)? - } - None => Val::Null, - }; - Ok((Flow::Return(v), None)) - } - Stmt::Break => Ok((Flow::Break, None)), - Stmt::Continue => Ok((Flow::Continue, None)), - Stmt::Exit => Ok((Flow::Exit, None)), - Stmt::Throw(e) => { - let v = self.eval(e)?; - self.charge(self.cost(&v))?; - // The thrown value becomes the error message the caller keeps: cut it as - // messages are. - Err(clip(self.to_str(&v))) - } - } - } - - fn iterate(&mut self) -> R<()> { - if self.loops_left == 0 { - return Err(LOOPED_TOO_MUCH.into()); - } - self.loops_left -= 1; - Ok(()) - } -} - -// ── built-in functions ────────────────────────────────────────────────────────────────────── - -/// `NaiveDate::num_days_from_ce` of 1900-01-01 minus one: a civil date's day count rebased so -/// that 1900-01-01 is FormCalc day 1 (and 1970-01-01 is day 25_568). -const FORMCALC_EPOCH_FROM_CE: i64 = 693_595; - -/// Days in one 400-year Gregorian cycle; the calendar repeats after it. -const DAYS_PER_400_YEARS: i64 = 146_097; - -/// Days from 1900-01-01 (day 1, as FormCalc counts) to a civil date. -/// -/// chrono's `NaiveDate` stops near ±262143, but FormCalc accepts day numbers up to `MAX_DAYS` -/// (years past 270000). The calendar repeats every 400 years, so chrono converts the date -/// within its cycle and whole cycles are added back; the result matches the proleptic Gregorian -/// count for every year. Callers pass dates `parse_date`/`civil_from_days` already validated; -/// anything else reads as 0. -fn days_from_civil(y: i64, m: u32, d: u32) -> i64 { - use chrono::Datelike; - let (cycles, year) = (i128::from(y.div_euclid(400)), y.rem_euclid(400)); - i32::try_from(year) - .ok() - .and_then(|year| chrono::NaiveDate::from_ymd_opt(year, m, d)) - .and_then(|date| { - let days = i128::from(date.num_days_from_ce()) - i128::from(FORMCALC_EPOCH_FROM_CE) + cycles * i128::from(DAYS_PER_400_YEARS); - i64::try_from(days).ok() - }) - .unwrap_or(0) -} - -/// The civil date (year, month, day) at FormCalc day number `n` (day 1 is 1900-01-01), for any -/// `n`: chrono converts the day within its 400-year cycle and the cycles are added back as years. -fn civil_from_days(n: i64) -> (i64, u32, u32) { - use chrono::Datelike; - // Days since CE day 0 (0000-12-31), in i128 so no `n` overflows. - let from_ce = i128::from(n) + i128::from(FORMCALC_EPOCH_FROM_CE); - let (cycles, rest) = (from_ce.div_euclid(i128::from(DAYS_PER_400_YEARS)), from_ce.rem_euclid(i128::from(DAYS_PER_400_YEARS))); - // `rest` is 0..146_097, so CE day `rest` lies in years 0..=400 (day 0 is 0000-12-31). - let date = i32::try_from(rest).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt); - match date { - Some(date) => { - let y = i128::from(date.year()) + cycles * 400; - (i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), date.month(), date.day()) - } - // Unreachable: every `rest` in 0..146_097 is a valid CE day. - None => (1900, 1, 1), - } -} - -/// Day numbers (and years) beyond this are refused rather than computed. -const MAX_DAYS: f64 = 1e8; - -const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; - -/// Parse `text` as a date by `picture` (`YYYY-MM-DD`, `MM/DD/YYYY`, `MMM D, YYYY`, …). -fn parse_date(text: &str, picture: &str) -> Option<(i64, u32, u32)> { - let pic: Vec = picture.chars().collect(); - let txt: Vec = text.trim().chars().collect(); - let (mut y, mut m, mut d) = (None, None, None); - let (mut i, mut j) = (0, 0); - while i < pic.len() { - let c = pic[i].to_ascii_uppercase(); - if c == 'Y' || c == 'M' || c == 'D' { - let mut run = 1; - while i + run < pic.len() && pic[i + run].to_ascii_uppercase() == c { - run += 1; - } - i += run; - if c == 'M' && run >= 3 { - // Month name. - let rest: String = txt.get(j..).unwrap_or(&[]).iter().collect(); - let lower = rest.to_ascii_lowercase(); - let found = MONTHS - .iter() - .position(|name| lower.starts_with(&name.to_ascii_lowercase()) || lower.starts_with(&name[..3].to_ascii_lowercase())); - let k = found?; - m = Some(k as u32 + 1); - let full = MONTHS[k].to_ascii_lowercase(); - j += if lower.starts_with(&full) { full.chars().count() } else { 3 }; - continue; - } - let width = if run == 1 { 2 } else { run }; - let mut digits = String::new(); - while j < txt.len() && txt[j].is_ascii_digit() && digits.len() < width { - digits.push(txt[j]); - j += 1; - } - if digits.is_empty() { - return None; - } - let v: i64 = digits.parse().ok()?; - match c { - 'Y' => y = Some(if run <= 2 { if v < 50 { 2000 + v } else { 1900 + v } } else { v }), - 'M' => m = Some(v as u32), - _ => d = Some(v as u32), - } - } else { - if txt.get(j) != Some(&pic[i]) { - return None; - } - i += 1; - j += 1; - } - } - let (y, m, d) = (y?, m.unwrap_or(1), d.unwrap_or(1)); - ((-200_000..=200_000).contains(&y) && (1..=12).contains(&m) && (1..=days_in(y, m)).contains(&d)).then_some((y, m, d)) -} - -/// Number of days in month `m` (1-12) of year `y`, leap years included. -pub(crate) fn days_in(y: i64, m: u32) -> u32 { - // The length of the month from chrono (the first of the next month, less the first of this - // one). An invalid month keeps the old 31-day default. - // Month lengths repeat every 400 years, so the year within its cycle is enough (and always - // in chrono's range, whatever `y` is). - let y = y.rem_euclid(400); - let first = |y: i64, m: u32| i32::try_from(y).ok().and_then(|y| chrono::NaiveDate::from_ymd_opt(y, m, 1)); - let next = if m == 12 { first(y + 1, 1) } else { first(y, m + 1) }; - match (first(y, m), next) { - (Some(a), Some(b)) => (b - a).num_days() as u32, - _ => 31, - } -} - -const WEEKDAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; - -fn format_date(y: i64, m: u32, d: u32, picture: &str) -> String { - let pic: Vec = picture.chars().collect(); - let mut out = String::new(); - let mut i = 0; - while i < pic.len() { - let c = pic[i].to_ascii_uppercase(); - if c == 'Y' || c == 'M' || c == 'D' { - let mut run = 1; - while i + run < pic.len() && pic[i + run].to_ascii_uppercase() == c { - run += 1; - } - i += run; - match (c, run) { - ('Y', 1 | 2) => out.push_str(&format!("{:02}", y.rem_euclid(100))), - ('Y', _) => out.push_str(&format!("{y:04}")), - ('M', 1) => out.push_str(&m.to_string()), - ('M', 2) => out.push_str(&format!("{m:02}")), - ('M', 3) => out.push_str(MONTHS.get((m as usize).saturating_sub(1)).map_or("", |n| &n[..3])), - ('M', _) => out.push_str(MONTHS.get((m as usize).saturating_sub(1)).copied().unwrap_or("")), - ('D', 1) => out.push_str(&d.to_string()), - (_, _) => out.push_str(&format!("{d:02}")), - } - } else if pic[i] == 'E' { - let mut run = 1; - while i + run < pic.len() && pic[i + run] == 'E' { - run += 1; - } - i += run; - // Day 1 (1900-01-01) was a Monday, so the serial modulo 7 indexes from Sunday. - let weekday = days_from_civil(y, m, d).rem_euclid(7) as usize; - match run { - 1 | 2 => out.push_str(&(weekday + 1).to_string()), - 3 => out.push_str(WEEKDAYS.get(weekday).map_or("", |n| &n[..3])), - _ => out.push_str(WEEKDAYS.get(weekday).copied().unwrap_or("")), - } - } else { - out.push(pic[i]); - i += 1; - } - } - out -} - -const ONES: [&str; 20] = [ - "Zero", - "One", - "Two", - "Three", - "Four", - "Five", - "Six", - "Seven", - "Eight", - "Nine", - "Ten", - "Eleven", - "Twelve", - "Thirteen", - "Fourteen", - "Fifteen", - "Sixteen", - "Seventeen", - "Eighteen", - "Nineteen", -]; -const TENS: [&str; 10] = ["", "", "Twenty", "Thirty", "Forty", "Fifty", "Sixty", "Seventy", "Eighty", "Ninety"]; - -fn words_below_1000(n: u64) -> String { - let mut parts = Vec::new(); - if n >= 100 { - parts.push(format!("{} Hundred", ONES[(n / 100) as usize])); - } - let r = n % 100; - if r >= 20 { - parts.push(if r.is_multiple_of(10) { - TENS[(r / 10) as usize].to_string() - } else { - format!("{}-{}", TENS[(r / 10) as usize], ONES[(r % 10) as usize]) - }); - } else if r > 0 || n == 0 { - parts.push(ONES[r as usize].to_string()); - } - parts.join(" ") -} - -/// `WordNum`: an integer in English words (up to the billions). -fn word_num(n: f64, cents: bool) -> String { - if !n.is_finite() || !(0.0..1e12).contains(&n) { - return String::new(); - } - let cents_total = (n * 100.0).round() as u64; - let whole = if cents { cents_total / 100 } else { n.trunc() as u64 }; - if whole >= 1_000_000_000_000 { - // Rounding to cents carried past the supported range. - return String::new(); - } - let mut parts = Vec::new(); - let groups = [(1_000_000_000u64, "Billion"), (1_000_000, "Million"), (1_000, "Thousand")]; - let mut rest = whole; - for (unit, name) in groups { - if rest >= unit { - parts.push(format!("{} {name}", words_below_1000(rest / unit))); - rest %= unit; - } - } - if rest > 0 || parts.is_empty() { - parts.push(words_below_1000(rest)); - } - let mut out = parts.join(" "); - if cents { - let c = cents_total % 100; - let dollars = if whole == 1 { "Dollar" } else { "Dollars" }; - let cent = if c == 1 { "Cent" } else { "Cents" }; - out.push_str(&format!(" {dollars} And {c:02} {cent}")); - } - out -} - -impl Interp<'_> { - /// A built-in function: its result is never a string past [`MAX_STRING`], and work on - /// long strings is charged to the step budget. - fn builtin(&mut self, name: &str, args: &[Expr]) -> R { - let v = self.builtin_inner(name, args)?; - if let Val::Str(s) = &v { - if s.len() > MAX_STRING { - return Err(STRING_TOO_LONG.into()); - } - self.charge((s.len() / 256) as u64)?; - } - Ok(v) - } - - fn builtin_inner(&mut self, name: &str, args: &[Expr]) -> R { - let lower = name.to_ascii_lowercase(); - let n = |v: f64| Ok(Val::Num(v)); - Ok(match lower.as_str() { - // ── arithmetic - "abs" => return n(self.arg_num(args, 0)?.abs()), - "ceil" => return n(self.arg_num(args, 0)?.ceil()), - "floor" => return n(self.arg_num(args, 0)?.floor()), - "mod" => { - let (a, b) = (self.arg_num(args, 0)?, self.arg_num(args, 1)?); - if b == 0.0 { - return Err("division by zero".into()); - } - Val::Num(a - b * (a / b).trunc()) - } - "round" => { - let a = self.arg_num(args, 0)?; - let places = if args.len() > 1 { self.arg_num(args, 1)?.clamp(0.0, 12.0) as i32 } else { 0 }; - let k = 10f64.powi(places); - let scaled = a * k; - if scaled.is_finite() { Val::Num(scaled.round() / k) } else { Val::Num(a) } - } - "count" => { - // Non-null values and objects: an empty field doesn't count, a subform does. - let vals = self.arg_values_keeping(args, true)?; - let counts = |v: &Val| match v { - Val::Node(i) => self.node(*i).is_some_and(|n| !matches!(n.kind, XfaKind::Field | XfaKind::ExclGroup) || !n.value.is_empty()), - other => !self.is_null(other), - }; - Val::Num(vals.iter().filter(|v| counts(v)).count() as f64) - } - "sum" | "avg" | "max" | "min" => { - let vals = self.arg_values(args)?; - let nums: Vec = vals.iter().map(|v| self.to_num(v)).collect(); - match lower.as_str() { - "sum" => Val::Num(nums.iter().sum()), - "avg" => { - if nums.is_empty() { - Val::Null - } else { - Val::Num(nums.iter().sum::() / nums.len() as f64) - } - } - "max" => nums.iter().copied().fold(None, |m: Option, x| Some(m.map_or(x, |m| m.max(x)))).map_or(Val::Null, Val::Num), - _ => nums.iter().copied().fold(None, |m: Option, x| Some(m.map_or(x, |m| m.min(x)))).map_or(Val::Null, Val::Num), - } - } - // ── logical - "choose" => { - let i = self.arg_num(args, 0)?; - if !i.is_finite() || i < 1.0 { - return Ok(Val::Null); - } - self.arg(args, i as usize)? - } - "exists" => { - let Some(e) = args.first() else { return Ok(Val::Num(0.0)) }; - // A name that resolves to nothing is the answer "no"; a limit the lookup ran - // into (time, steps, depth, size) still stops the script. - match self.all_of(e) { - Ok(v) => Val::Num(if v.is_empty() { 0.0 } else { 1.0 }), - Err(e) if is_limit(&e) => return Err(e), - Err(_) => Val::Num(0.0), - } - } - "hasvalue" => { - let v = self.arg(args, 0)?; - Val::Num(if self.is_null(&v) || matches!(&v, Val::Str(s) if s.trim().is_empty()) { 0.0 } else { 1.0 }) - } - "oneof" => { - let v = self.arg(args, 0)?; - let rest = self.arg_values(args.get(1..).unwrap_or_default())?; - let mut hit = false; - for x in &rest { - // Each comparison copies and compares `v`: charged per item. - self.charge(self.cost(&v).saturating_add(self.cost(x)).saturating_add(1))?; - if self.binary("==", v.clone(), x.clone()).is_ok_and(|r| self.truthy(&r)) { - hit = true; - break; - } - } - Val::Num(if hit { 1.0 } else { 0.0 }) - } - "within" => { - let v = self.arg(args, 0)?; - let (lo, hi) = (self.arg(args, 1)?, self.arg(args, 2)?); - let ok = self.binary(">=", v.clone(), lo).is_ok_and(|r| self.truthy(&r)) && self.binary("<=", v, hi).is_ok_and(|r| self.truthy(&r)); - Val::Num(if ok { 1.0 } else { 0.0 }) - } - // ── strings - "at" => { - let (s, sub) = (self.arg_str(args, 0)?, self.arg_str(args, 1)?); - Val::Num(if sub.is_empty() { 0.0 } else { s.find(&sub).map_or(0.0, |b| s[..b].chars().count() as f64 + 1.0) }) - } - "concat" => { - let mut out = String::new(); - for i in 0..args.len() { - out.push_str(&self.arg_str(args, i)?); - if out.len() > MAX_STRING { - return Err(STRING_TOO_LONG.into()); - } - } - Val::Str(out) - } - "left" => { - let (s, k) = (self.arg_str(args, 0)?, self.arg_num(args, 1)?.max(0.0) as usize); - Val::Str(s.chars().take(k).collect()) - } - "right" => { - let (s, k) = (self.arg_str(args, 0)?, self.arg_num(args, 1)?.max(0.0) as usize); - let len = s.chars().count(); - Val::Str(s.chars().skip(len.saturating_sub(k)).collect()) - } - "len" => Val::Num(self.arg_str(args, 0)?.chars().count() as f64), - "lower" => Val::Str(self.arg_str(args, 0)?.to_lowercase()), - "upper" => Val::Str(self.arg_str(args, 0)?.to_uppercase()), - "ltrim" => Val::Str(self.arg_str(args, 0)?.trim_start().to_string()), - "rtrim" => Val::Str(self.arg_str(args, 0)?.trim_end().to_string()), - "space" => { - let k = self.arg_num(args, 0)?.clamp(0.0, MAX_STRING as f64) as usize; - Val::Str(" ".repeat(k)) - } - "str" => { - let v = self.arg_num(args, 0)?; - let width = if args.len() > 1 { self.arg_num(args, 1)?.clamp(0.0, 1000.0) as usize } else { 10 }; - let prec = if args.len() > 2 { self.arg_num(args, 2)?.clamp(0.0, 20.0) as usize } else { 0 }; - let text = fixed(v, prec); - Val::Str(if text.len() > width { "*".repeat(width) } else { format!("{text:>width$}") }) - } - "substr" => { - let s = self.arg_str(args, 0)?; - let start = self.arg_num(args, 1)?.max(1.0) as usize; - let len = self.arg_num(args, 2)?.max(0.0) as usize; - Val::Str(s.chars().skip(start - 1).take(len).collect()) - } - "stuff" => { - let s = self.arg_str(args, 0)?; - let start = self.arg_num(args, 1)?.max(1.0) as usize; - let del = self.arg_num(args, 2)?.max(0.0) as usize; - let ins = if args.len() > 3 { self.arg_str(args, 3)? } else { String::new() }; - let chars: Vec = s.chars().collect(); - let a = start.saturating_sub(1).min(chars.len()); - let b = a.saturating_add(del).min(chars.len()); - let out: String = chars[..a].iter().chain(ins.chars().collect::>().iter()).chain(chars[b..].iter()).collect(); - if out.len() > MAX_STRING { - return Err(STRING_TOO_LONG.into()); - } - Val::Str(out) - } - "replace" => { - let (s, old) = (self.arg_str(args, 0)?, self.arg_str(args, 1)?); - let new = if args.len() > 2 { self.arg_str(args, 2)? } else { String::new() }; - if old.is_empty() { - return Ok(Val::Str(s)); - } - let grown = s.matches(old.as_str()).count().saturating_mul(new.len()).saturating_add(s.len()); - if grown > MAX_STRING { - return Err(STRING_TOO_LONG.into()); - } - Val::Str(s.replace(&old, &new)) - } - "wordnum" => { - let v = self.arg_num(args, 0)?; - let kind = if args.len() > 1 { self.arg_num(args, 1)? } else { 0.0 }; - Val::Str(word_num(v, kind == 2.0)) - } - "uuid" => Val::Str(format!("{:032x}", (self.steps as u128).wrapping_mul(0x9E37_79B9_7F4A_7C15_F39C_C060_5CED_C835))), - "encode" | "decode" => { - let s = self.arg_str(args, 0)?; - let kind = if args.len() > 1 { self.arg_str(args, 1)?.to_ascii_lowercase() } else { "url".into() }; - Val::Str(match (lower.as_str(), kind.as_str()) { - ("encode", "html" | "xml") => s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """), - ("decode", "html" | "xml") => s.replace("<", "<").replace(">", ">").replace(""", "\"").replace("&", "&"), - ("encode", _) => s - .bytes() - .map(|b| if b.is_ascii_alphanumeric() || b"-_.~".contains(&b) { (b as char).to_string() } else { format!("%{b:02X}") }) - .collect(), - (_, _) => { - let mut out = Vec::new(); - let b = s.as_bytes(); - let mut i = 0; - while i < b.len() { - if b[i] == b'%' - && let Some(h) = b - .get(i + 1..i + 3) - .filter(|h| h.iter().all(u8::is_ascii_hexdigit)) - .and_then(|h| std::str::from_utf8(h).ok()) - .and_then(|h| u8::from_str_radix(h, 16).ok()) - { - out.push(h); - i += 3; - } else { - out.push(b[i]); - i += 1; - } - } - String::from_utf8_lossy(&out).into_owned() - } - }) - } - "format" => { - // Number pictures: `9` digit, `z` optional digit, `,` separator, `.` point, `$`. - let pic = self.arg_str(args, 0)?; - let v = self.arg(args, 1)?; - if pic.to_ascii_uppercase().contains("YY") || pic.to_ascii_uppercase().contains("DD") { - let text = self.to_str(&v); - let days = parse_num(&text).filter(|d| is_numeric_text(&text) && d.abs() < MAX_DAYS).map(|d| d as i64); - return Ok(match days { - Some(d) => { - let (y, m, dd) = civil_from_days(d); - Val::Str(format_date(y, m, dd, &pic)) - } - None => parse_date(&text, "YYYY-MM-DD").map_or(Val::Str(text), |(y, m, d)| Val::Str(format_date(y, m, d, &pic))), - }); - } - let num = self.to_num(&v); - let decimals = pic.split_once('.').map_or(0, |(_, f)| f.chars().filter(|c| matches!(c, '9' | 'z' | 'Z')).count()).min(20); - // `9` in the integer part forces a digit; `z` does not. - let min_int = pic.split('.').next().map_or(0, |i| i.chars().filter(|c| *c == '9').count()).min(20); - let width = min_int + if decimals > 0 { decimals + 1 } else { 0 }; - let grouped = pic.contains(','); - let mut text = format!("{:0>width$}", fixed(num.abs(), decimals)); - if grouped { - let (int, frac) = text.split_once('.').map_or((text.as_str(), ""), |(a, b)| (a, b)); - let mut g = String::new(); - for (k, c) in int.chars().enumerate() { - if k > 0 && (int.len() - k) % 3 == 0 { - g.push(','); - } - g.push(c); - } - text = if frac.is_empty() { g } else { format!("{g}.{frac}") }; - } - let nonzero = text.chars().any(|c| ('1'..='9').contains(&c)); - if pic.contains('$') { - text = format!("${text}"); - } - if num < 0.0 && nonzero { - text = format!("-{text}"); - } - Val::Str(text) - } - "parse" => { - let pic = self.arg_str(args, 0)?; - let s = self.arg_str(args, 1)?; - if pic.to_ascii_uppercase().contains("YY") { - // The canonical form of a date is ISO. - return Ok(parse_date(&s, &pic).map_or(Val::Null, |(y, m, d)| Val::Str(format_date(y, m, d, "YYYY-MM-DD")))); - } - let cleaned: String = s.chars().filter(|c| c.is_ascii_digit() || *c == '.' || *c == '-').collect(); - parse_num(&cleaned).map_or(Val::Null, Val::Num) - } - // ── dates and times (days since 1900-01-01 = 1; milliseconds since midnight) - "date" => { - #[cfg(not(target_arch = "wasm32"))] - { - let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0); - Val::Num((secs.div_euclid(86_400) + 25_568) as f64) - } - #[cfg(target_arch = "wasm32")] - { - Val::Num(25_568.0) - } - } - "time" => { - #[cfg(not(target_arch = "wasm32"))] - { - let ms = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as i64).unwrap_or(0); - Val::Num(ms.rem_euclid(86_400_000) as f64) - } - #[cfg(target_arch = "wasm32")] - { - Val::Num(0.0) - } - } - "date2num" | "isodate2num" => { - let s = self.arg_str(args, 0)?; - let pic = if lower == "isodate2num" || args.len() < 2 { "YYYY-MM-DD".to_string() } else { self.arg_str(args, 1)? }; - let parsed = parse_date(&s, &pic).or_else(|| parse_date(&s, "YYYYMMDD")).or_else(|| parse_date(&s, "YYYY-MM-DD")); - parsed.map_or(Val::Num(0.0), |(y, m, d)| Val::Num(days_from_civil(y, m, d) as f64)) - } - "num2date" => { - let days = self.arg_num(args, 0)?; - let pic = if args.len() > 1 { self.arg_str(args, 1)? } else { "MMM D, YYYY".into() }; - if !days.is_finite() || days.abs() >= MAX_DAYS { - return Ok(Val::Null); - } - let (y, m, d) = civil_from_days(days as i64); - Val::Str(format_date(y, m, d, &pic)) - } - "datefmt" | "localdatefmt" => { - let style = if args.is_empty() { 0.0 } else { self.arg_num(args, 0)? }; - Val::Str( - match style as i32 { - 1 => "M/D/YY", - 2 => "MMM D, YYYY", - 3 => "MMMM D, YYYY", - 4 => "EEEE, MMMM D, YYYY", - _ => "MMM D, YYYY", - } - .into(), - ) - } - "timefmt" | "localtimefmt" => Val::Str("HH:MM:SS".into()), - "num2time" | "num2gmtime" => { - let ms = self.arg_num(args, 0)?; - if !ms.is_finite() || ms < 0.0 { - return Ok(Val::Null); - } - let s = (ms / 1000.0) as u64; - Val::Str(format!("{:02}:{:02}:{:02}", (s / 3600) % 24, (s / 60) % 60, s % 60)) - } - "time2num" | "isotime2num" => { - let s = self.arg_str(args, 0)?; - // ISO 8601 allows a leading "T", a trailing "Z" and fractional seconds. - let t = s.trim(); - let t = t.strip_prefix(['T', 't']).unwrap_or(t); - let t = t.strip_suffix(['Z', 'z']).unwrap_or(t); - let mut parts = t.split(':'); - let mut whole = || parts.next().and_then(|p| p.trim().parse::().ok()).unwrap_or(0).min(1_000_000); - let (h, m) = (whole(), whole()); - let sec = - parts.next().and_then(|p| p.trim().parse::().ok()).filter(|v| v.is_finite() && *v >= 0.0).unwrap_or(0.0).min(1_000_000.0); - Val::Num(((h * 3600 + m * 60) * 1000) as f64 + (sec * 1000.0).round()) - } - // ── financial - "fv" => { - let (p, r, k) = (self.arg_num(args, 0)?, self.arg_num(args, 1)?, self.arg_num(args, 2)?); - Val::Num(if r == 0.0 { p * k } else { p * (((1.0 + r).powf(k) - 1.0) / r) }) - } - "pv" => { - let (p, r, k) = (self.arg_num(args, 0)?, self.arg_num(args, 1)?, self.arg_num(args, 2)?); - Val::Num(if r == 0.0 { p * k } else { p * ((1.0 - (1.0 + r).powf(-k)) / r) }) - } - "pmt" => { - let (pv, r, k) = (self.arg_num(args, 0)?, self.arg_num(args, 1)?, self.arg_num(args, 2)?); - if k <= 0.0 { - return Err("Pmt needs a positive number of periods".into()); - } - Val::Num(if r == 0.0 { pv / k } else { pv * r / (1.0 - (1.0 + r).powf(-k)) }) - } - "term" => { - let (p, r, fv) = (self.arg_num(args, 0)?, self.arg_num(args, 1)?, self.arg_num(args, 2)?); - if p <= 0.0 || r <= 0.0 || fv <= 0.0 { - return Err("Term needs positive arguments".into()); - } - Val::Num(((fv * r / p) + 1.0).ln() / (1.0 + r).ln()) - } - "cterm" => { - let (r, fv, pv) = (self.arg_num(args, 0)?, self.arg_num(args, 1)?, self.arg_num(args, 2)?); - if r <= 0.0 || fv <= 0.0 || pv <= 0.0 { - return Err("CTerm needs positive arguments".into()); - } - Val::Num((fv / pv).ln() / (1.0 + r).ln()) - } - "rate" => { - let (fv, pv, k) = (self.arg_num(args, 0)?, self.arg_num(args, 1)?, self.arg_num(args, 2)?); - if fv <= 0.0 || pv <= 0.0 || k <= 0.0 { - return Err("Rate needs positive arguments".into()); - } - Val::Num((fv / pv).powf(1.0 / k) - 1.0) - } - "npv" => { - let r = self.arg_num(args, 0)?; - let flows = self.arg_values(args.get(1..).unwrap_or_default())?; - Val::Num(flows.iter().enumerate().map(|(i, v)| self.to_num(v) / (1.0 + r).powi(i as i32 + 1)).sum()) - } - "ipmt" | "ppmt" => { - let (pv, r, pmt, first, count) = ( - self.arg_num(args, 0)?, - self.arg_num(args, 1)?, - self.arg_num(args, 2)?, - self.arg_num(args, 3)?.max(1.0), - self.arg_num(args, 4)?.max(1.0), - ); - let (mut balance, mut interest, mut principal) = (pv, 0.0, 0.0); - for period in 1..=((first + count - 1.0).min(10_000.0) as u32) { - self.step()?; - let i = balance * r; - let p = (pmt - i).min(balance); - if period >= first as u32 { - interest += i; - principal += p; - } - balance -= p; - if balance <= 0.0 { - break; - } - } - Val::Num(if lower == "ipmt" { interest } else { principal }) - } - "apr" => { - let (principal, payment, periods) = (self.arg_num(args, 0)?, self.arg_num(args, 1)?, self.arg_num(args, 2)?); - if principal <= 0.0 || payment <= 0.0 || periods <= 0.0 { - return Err("Apr needs positive arguments".into()); - } - // Bisection on the monthly rate. - let (mut lo, mut hi) = (0.0f64, 1.0f64); - for _ in 0..200 { - let mid = (lo + hi) / 2.0; - let pmt = if mid == 0.0 { principal / periods } else { principal * mid / (1.0 - (1.0 + mid).powf(-periods)) }; - if pmt > payment { - hi = mid; - } else { - lo = mid; - } - } - Val::Num((lo + hi) / 2.0 * 12.0) - } - // ── units and references - "unittype" => { - let s = self.arg_str(args, 0)?; - let unit: String = s.trim().chars().skip_while(|c| c.is_ascii_digit() || *c == '.' || *c == '-' || *c == '+').collect(); - Val::Str( - match unit.trim().to_ascii_lowercase().as_str() { - "in" | "inch" | "inches" => "in", - "mm" | "millimeter" | "millimeters" => "mm", - "cm" | "centimeter" | "centimeters" => "cm", - "pt" | "point" | "points" => "pt", - "pc" | "pica" => "pc", - _ => "in", - } - .into(), - ) - } - "unitvalue" => { - let s = self.arg_str(args, 0)?; - let to = if args.len() > 1 { self.arg_str(args, 1)? } else { String::new() }; - let v = parse_num(&s).unwrap_or(0.0); - let unit: String = s.trim().chars().skip_while(|c| c.is_ascii_digit() || *c == '.' || *c == '-' || *c == '+').collect(); - let per_in = |u: &str| match u.trim().to_ascii_lowercase().as_str() { - "mm" | "millimeter" | "millimeters" => 25.4, - "cm" | "centimeter" | "centimeters" => 2.54, - "pt" | "point" | "points" => 72.0, - "pc" | "pica" => 6.0, - _ => 1.0, - }; - let target = if to.trim().is_empty() { unit.clone() } else { to }; - Val::Num(v / per_in(&unit) * per_in(&target)) - } - "ref" => { - let Some(e) = args.first() else { return Ok(Val::Null) }; - self.eval(e)? - } - "eval" => { - let src = self.arg_str(args, 0)?; - self.charge((src.len() / 16) as u64)?; - let body = parse(&src)?; - let (_, v) = self.block(&body)?; - v - } - "get" | "post" | "put" => return Err(format!("{name}: network access is not available to form scripts")), - _ => return Err(format!("unknown function {name}")), - }) - } -} - // ── entry points ──────────────────────────────────────────────────────────────────────────── /// Run a FormCalc `script` for `event` on the form `root`, as [`crate::xfa::run_xfa`] runs a @@ -2676,4 +227,22 @@ mod tests { assert_eq!(parse_num("12abc"), Some(12.0)); assert_eq!(parse_num("abc"), None); } + + /// A number prints to at most fifteen significant digits, so the binary noise of a value + /// with six digits before the point (279893.88 is 279893.880000000004656… as a double) stays + /// hidden, while small values keep their twelve decimals. + #[test] + fn num_text_prints_at_most_fifteen_significant_digits() { + assert_eq!(num_text(279_893.88), "279893.88"); + assert_eq!(num_text(-279_893.88), "-279893.88"); + assert_eq!(num_text(279_893.88 + 291_774.12 + 304_171.06 + 317_107.44 + 273_970.85), "1466917.35"); + assert_eq!(num_text(1_000_000.0 / 3.0), "333333.333333333"); + assert_eq!(num_text(1.0 / 3.0), "0.333333333333"); + assert_eq!(num_text(0.3 - 0.1 - 0.2), "0", "noise below twelve decimals is zero"); + // Past fifteen digits before the point no decimals are left, and the integer keeps its zeros. + assert_eq!(num_text(1e15 + 0.5), "1000000000000000"); + assert_eq!(num_text(1e20), "100000000000000000000"); + assert_eq!(num_text(-1e20), "-100000000000000000000"); + assert_eq!(num_text(f64::MAX).len(), 309); + } } diff --git a/crates/js/src/formcalc/access.rs b/crates/js/src/formcalc/access.rs new file mode 100644 index 0000000000..bea902c752 --- /dev/null +++ b/crates/js/src/formcalc/access.rs @@ -0,0 +1,436 @@ +#[cfg(not(target_arch = "wasm32"))] +use super::CLOCK_EVERY; +use super::ast::{Expr, Idx}; +use super::value::{Val, num_text, parse_num}; +use super::{ + HNode, HashSet, Interp, LIST_TOO_LONG, MAX_LIST, MAX_PARSE_DEPTH, MAX_STEPS, R, RAN_TOO_LONG, child_named, descendants_named, instances, + resolve_som, +}; + +impl Interp<'_> { + pub(super) fn step(&mut self) -> R<()> { + self.charge(1) + } + + /// `n` units of work done (a step per statement or expression, more for work on long + /// strings and lists). + pub(super) fn charge(&mut self, n: u64) -> R<()> { + let before = self.steps; + self.steps = self.steps.saturating_add(n); + if self.steps > MAX_STEPS { + return Err(RAN_TOO_LONG.into()); + } + #[cfg(not(target_arch = "wasm32"))] + if self.steps / CLOCK_EVERY != before / CLOCK_EVERY + && let Some(d) = self.deadline + && std::time::Instant::now() > d + { + return Err(RAN_TOO_LONG.into()); + } + #[cfg(target_arch = "wasm32")] + let _ = before; + Ok(()) + } + + /// `more` nodes into `out`, each once, never past [`MAX_LIST`]. + pub(super) fn gather(&mut self, out: &mut Vec, seen: &mut HashSet, more: impl IntoIterator) -> R<()> { + for i in more { + if seen.insert(i) { + out.push(i); + if out.len() > MAX_LIST { + return Err(LIST_TOO_LONG.into()); + } + } + } + self.charge(1) + } + + /// The nodes of `v`, each once. + pub(super) fn nodes_list(&self, v: &Val) -> Vec { + let mut seen = HashSet::new(); + self.nodes_of(v).into_iter().filter(|i| seen.insert(*i)).collect() + } + + pub(super) fn node(&self, i: usize) -> Option<&HNode> { + self.h.nodes.get(i).filter(|n| !n.gone) + } + + // ── coercions + + /// A node's value as a FormCalc value. + fn node_value(&self, i: usize) -> Val { + let Some(n) = self.node(i) else { return Val::Null }; + if n.value.is_empty() { + return Val::Null; + } + if n.numeric + && let Some(v) = parse_num(&n.value) + { + return Val::Num(v); + } + Val::Str(n.value.clone()) + } + + /// What copying `v` (or the values it stands for) costs, in steps: one per 256 bytes, so + /// copying long strings over and over runs out of steps even where there is no clock + /// (wasm). A form object counts its value; a list its items too. + pub(super) fn cost(&self, v: &Val) -> u64 { + let mut bytes = 0usize; + let mut todo = vec![v]; + while let Some(v) = todo.pop() { + match v { + Val::Str(s) => bytes = bytes.saturating_add(s.len()), + Val::Node(i) => bytes = bytes.saturating_add(self.node(*i).map_or(0, |n| n.value.len())), + Val::List(items) => { + bytes = bytes.saturating_add(items.len().saturating_mul(16)); + todo.extend(items.iter()); + } + _ => {} + } + } + (bytes / 256) as u64 + } + + /// [`Self::deref`], charged for the copy. + pub(super) fn take(&mut self, v: Val) -> R { + self.charge(self.cost(&v))?; + Ok(self.deref(v)) + } + + /// Objects become their values; lists of objects become lists of values. + pub(super) fn deref(&self, v: Val) -> Val { + match v { + Val::Node(i) => self.node_value(i), + Val::List(items) => Val::List(items.into_iter().map(|x| self.deref(x)).collect()), + other => other, + } + } + + pub(super) fn to_num(&self, v: &Val) -> f64 { + match v { + Val::Null => 0.0, + Val::Num(n) => *n, + Val::Str(s) => parse_num(s).unwrap_or(0.0), + Val::Node(i) => self.to_num(&self.node_value(*i)), + Val::List(items) => items.first().map_or(0.0, |x| self.to_num(x)), + _ => 0.0, + } + } + + pub(super) fn to_str(&self, v: &Val) -> String { + match v { + Val::Null => String::new(), + Val::Num(n) => num_text(*n), + Val::Str(s) => s.clone(), + Val::Node(i) => self.to_str(&self.node_value(*i)), + Val::List(items) => items.first().map_or(String::new(), |x| self.to_str(x)), + Val::Host => "host".into(), + Val::Layout => "layout".into(), + Val::Event => "event".into(), + Val::Xfa => "xfa".into(), + Val::Im(..) => "instanceManager".into(), + Val::Sink => String::new(), + } + } + + pub(super) fn truthy(&self, v: &Val) -> bool { + match v { + Val::Null => false, + Val::Num(n) => *n != 0.0 && !n.is_nan(), + Val::Str(s) => parse_num(s).is_some_and(|n| n != 0.0), + Val::Node(i) => { + let d = self.node_value(*i); + self.truthy(&d) + } + Val::List(items) => !items.is_empty(), + _ => true, + } + } + + pub(super) fn is_null(&self, v: &Val) -> bool { + match v { + Val::Null => true, + Val::Node(i) => self.node(*i).is_none_or(|n| n.value.is_empty()), + Val::List(items) => items.is_empty(), + _ => false, + } + } + + // ── scopes + + fn lookup(&self, name: &str) -> Option { + self.scopes.iter().rev().find_map(|s| s.get(name).cloned()) + } + + pub(super) fn set_var(&mut self, name: &str, v: Val) -> bool { + for s in self.scopes.iter_mut().rev() { + if let Some(slot) = s.get_mut(name) { + *slot = v; + return true; + } + } + false + } + + pub(super) fn declare(&mut self, name: &str, v: Val) { + if let Some(s) = self.scopes.last_mut() { + s.insert(name.to_string(), v); + } + } + + // ── accessors + + /// The objects an identifier names from the current node: `$`, the special objects, an + /// instance manager (`_name`), a variable, or a form object by name (children first, then + /// the containing subforms). + pub(super) fn ident(&self, name: &str) -> R { + if let Some(v) = self.lookup(name) { + return Ok(v); + } + let lower = name.to_ascii_lowercase(); + Ok(match lower.as_str() { + "$" | "this" => Val::Node(self.h.current), + "$host" => Val::Host, + "$layout" => Val::Layout, + "$event" => Val::Event, + "$form" | "$data" | "$dataset" | "$datasets" => Val::Node(0), + "$record" => Val::Node(if self.node(1).is_some() { 1 } else { 0 }), + "$template" | "$xfa" => Val::Xfa, + "xfa" => Val::Xfa, + _ => { + if let Some(child) = name.strip_prefix('_') { + // The instance manager of `child`, looked for like a name. + let found = resolve_som(self.h, self.h.current, child); + let Some(&first) = found.first() else { return Err(format!("unknown instance manager _{child}")) }; + let parent = self.node(first).and_then(|n| n.parent).ok_or_else(|| format!("unknown instance manager _{child}"))?; + return Ok(Val::Im(parent, child.to_string())); + } + let found = resolve_som(self.h, self.h.current, name); + match found.as_slice() { + [] => return Err(format!("unknown name {name}")), + [one] => Val::Node(*one), + many => Val::List(many.iter().map(|i| Val::Node(*i)).collect()), + } + } + }) + } + + /// Every object a value stands for. + pub(super) fn nodes_of(&self, v: &Val) -> Vec { + match v { + Val::Node(i) => vec![*i], + Val::List(items) => items.iter().flat_map(|x| self.nodes_of(x)).collect(), + _ => Vec::new(), + } + } + + pub(super) fn member(&mut self, base: Val, name: &str) -> R { + match base { + Val::Node(i) => self.node_member(i, name), + Val::List(items) => { + let mut out = Vec::new(); + let mut seen = HashSet::new(); + for it in items { + match self.member(it, name)? { + Val::List(more) => { + for m in more { + self.list_push(&mut out, &mut seen, m)?; + } + } + Val::Null => {} + v => self.list_push(&mut out, &mut seen, v)?, + } + } + Ok(match out.len() { + 0 => Val::Null, + 1 => out.remove(0), + _ => Val::List(out), + }) + } + Val::Host => Ok(match name.to_ascii_lowercase().as_str() { + "numpages" => Val::Num(self.h.doc.page_count as f64), + "currentpage" => Val::Num(self.h.doc.page as f64), + "name" => Val::Str("Acrobat".into()), + "apptype" => Val::Str("Exchange-Pro".into()), + "version" => Val::Str("11".into()), + "variation" => Val::Str("Full".into()), + "language" => Val::Str("en".into()), + "platform" => Val::Str(crate::platform().into()), + "title" => Val::Str(self.h.doc.file_name.clone()), + "calculationsenabled" | "validationsenabled" => Val::Num(1.0), + _ => Val::Sink, + }), + Val::Layout => Ok(Val::Sink), + Val::Event => Ok(match name.to_ascii_lowercase().as_str() { + "name" => Val::Str(self.event.activity.clone()), + "newtext" | "fulltext" => Val::Str(self.event.new_text.clone()), + "prevtext" => Val::Str(self.event.prev_text.clone()), + "change" => Val::Str(String::new()), + "target" => Val::Node(self.h.current), + "selstart" | "selend" | "cancelaction" | "reenter" | "shift" | "modifier" | "keydown" | "commitkey" => Val::Num(0.0), + _ => Val::Sink, + }), + Val::Xfa => Ok(match name.to_ascii_lowercase().as_str() { + "form" | "datasets" | "data" => Val::Node(0), + "record" => Val::Node(if self.node(1).is_some() { 1 } else { 0 }), + "host" => Val::Host, + "layout" => Val::Layout, + "event" => Val::Event, + _ => Val::Sink, + }), + Val::Im(parent, child) => Ok(match name.to_ascii_lowercase().as_str() { + "count" => Val::Num(instances(self.h, parent, &child).len() as f64), + "min" => Val::Num(instances(self.h, parent, &child).first().and_then(|i| self.node(*i)).map_or(0.0, |n| n.occur_min as f64)), + "max" => Val::Num( + instances(self.h, parent, &child).first().and_then(|i| self.node(*i)).and_then(|n| n.occur_max).map_or(-1.0, |m| m as f64), + ), + "classname" => Val::Str("instanceManager".into()), + _ => Val::Sink, + }), + Val::Sink => Ok(Val::Sink), + Val::Null => Err(format!("no object to take {name} from")), + Val::Num(_) | Val::Str(_) => Err(format!("{} has no property {name}", self.to_str(&base))), + } + } + + /// `v` onto `out`: a node only once, never past [`MAX_LIST`]. + fn list_push(&mut self, out: &mut Vec, seen: &mut HashSet, v: Val) -> R<()> { + if let Val::Node(i) = v + && !seen.insert(i) + { + return Ok(()); + } + out.push(v); + if out.len() > MAX_LIST { + return Err(LIST_TOO_LONG.into()); + } + self.charge(1) + } + + /// Property `name` of form object `i` as FormCalc reads it: its value, attributes, + /// relatives and children by name. + fn node_member(&mut self, i: usize, name: &str) -> R { + let Some(n) = self.node(i) else { return Ok(Val::Null) }; + Ok(match name { + "formattedValue" => match &n.formatted { + Some(f) => Val::Str(f.clone()), + None => self.node_value(i), + }, + "rawValue" | "value" => self.node_value(i), + "name" => Val::Str(n.name.clone()), + "somExpression" => Val::Str(n.som.clone()), + "className" => Val::Str(n.kind.class_name().into()), + "index" | "instanceIndex" => Val::Num(n.index as f64), + "presence" => Val::Str(n.presence.clone()), + "access" => Val::Str(n.access.clone()), + "isNull" => Val::Num(if n.value.is_empty() { 1.0 } else { 0.0 }), + "mandatory" => Val::Str("disabled".into()), + "parent" => n.parent.map_or(Val::Null, Val::Node), + "nodes" => Val::List(n.children.iter().copied().filter(|c| self.node(*c).is_some()).map(Val::Node).collect()), + "all" => match n.parent { + Some(p) => Val::List(instances(self.h, p, &n.name).into_iter().map(Val::Node).collect()), + None => Val::Node(i), + }, + "instanceManager" => match n.parent { + Some(p) => Val::Im(p, n.name.clone()), + None => Val::Null, + }, + "border" | "ui" | "font" | "caption" | "fillColor" | "borderColor" | "fontColor" | "margin" | "para" | "assist" | "validate" + | "format" | "items" | "bind" | "keep" | "h" | "w" | "x" | "y" | "minH" | "maxH" | "minW" | "maxW" | "layout" | "model" + | "oneOfChild" | "locale" | "relevant" | "colSpan" | "dataNode" | "defaultValue" | "editValue" | "selectedIndex" | "event" + | "calculate" | "traversal" | "extras" | "desc" => Val::Sink, + other => { + if let Some(child) = other.strip_prefix('_') { + return Ok(if child_named(self.h, i, child).is_some() { Val::Im(i, child.to_string()) } else { Val::Null }); + } + let found = instances(self.h, i, other); + match found.as_slice() { + [] => Val::Null, + [one] => Val::Node(*one), + many => Val::List(many.iter().map(|k| Val::Node(*k)).collect()), + } + } + }) + } + + /// `base[n]` / `base[*]`: all the instances the last step named, then one or all. + pub(super) fn index(&mut self, base: &Expr, idx: &Idx) -> R { + let all = self.all_of(base)?; + match idx { + Idx::All => Ok(Val::List(all.into_iter().map(Val::Node).collect())), + Idx::Abs(e) => { + let v = self.eval(e)?; + let n = self.to_num(&v); + if !n.is_finite() || n < 0.0 { + return Ok(Val::Null); + } + Ok(all.get(n as usize).map_or(Val::Null, |i| Val::Node(*i))) + } + Idx::Rel(k) => { + // From the instance the script runs in (`row[-1]` in a row is the row above), + // else from the first. + let here = all.iter().position(|&i| self.encloses(i, self.h.current)).unwrap_or(0); + let at = (here as i64).saturating_add(*k); + Ok(usize::try_from(at).ok().and_then(|at| all.get(at)).map_or(Val::Null, |i| Val::Node(*i))) + } + } + } + + /// Whether `node` is `inner` or one of its containers. + fn encloses(&self, node: usize, inner: usize) -> bool { + let mut at = Some(inner); + let mut guard = 0; + while let Some(i) = at + && guard < MAX_PARSE_DEPTH + { + if i == node { + return true; + } + at = self.h.nodes.get(i).and_then(|n| n.parent); + guard += 1; + } + false + } + + /// Every instance an accessor names (`Row` → all rows, not just the first), each once. + pub(super) fn all_of(&mut self, e: &Expr) -> R> { + let mut out = Vec::new(); + let mut seen = HashSet::new(); + match e { + Expr::Ident(name) => { + if self.lookup(name).is_some() || name.starts_with('$') || name.eq_ignore_ascii_case("xfa") { + let v = self.eval(e)?; + return Ok(self.nodes_list(&v)); + } + // The name resolves to its first instance; `row[*]` means every sibling of it. + for first in resolve_som(self.h, self.h.current, name) { + match self.node(first).and_then(|n| n.parent.map(|p| (p, n.name.clone()))) { + Some((p, n)) => self.gather(&mut out, &mut seen, instances(self.h, p, &n))?, + None => self.gather(&mut out, &mut seen, [first])?, + } + } + } + Expr::Member(base, name) => { + let b = self.eval(base)?; + for i in self.nodes_list(&b) { + self.gather(&mut out, &mut seen, instances(self.h, i, name))?; + } + if out.is_empty() { + let v = self.member(b, name)?; + out = self.nodes_list(&v); + } + } + Expr::Descend(base, name) => { + let b = self.eval(base)?; + for i in self.nodes_list(&b) { + self.gather(&mut out, &mut seen, descendants_named(self.h, i, name, 0))?; + } + } + other => { + let v = self.eval(other)?; + out = self.nodes_list(&v); + } + } + Ok(out) + } +} diff --git a/crates/js/src/formcalc/ast.rs b/crates/js/src/formcalc/ast.rs new file mode 100644 index 0000000000..ab453a8061 --- /dev/null +++ b/crates/js/src/formcalc/ast.rs @@ -0,0 +1,48 @@ +use super::{Func, Rc}; + +// ── syntax ────────────────────────────────────────────────────────────────────────────────── + +#[derive(Clone, Debug, PartialEq)] +pub(super) enum Expr { + Num(f64), + Str(String), + Null, + Ident(String), + /// `base.name` + Member(Box, String), + /// `base[n]`, `base[*]`, `base[-n]` + Index(Box, Idx), + /// `base..name` + Descend(Box, String), + Call(Box, Vec), + Neg(Box), + Not(Box), + Bin(&'static str, Box, Box), +} + +#[derive(Clone, Debug, PartialEq)] +pub(super) enum Idx { + /// `[*]`: every instance. + All, + /// `[n]`: the n-th instance (from 0). + Abs(Box), + /// `[-n]` / `[+n]`: n instances before or after the one the script runs in. + Rel(i64), +} + +#[derive(Clone, Debug, PartialEq)] +pub(super) enum Stmt { + Var(String, Option), + Assign(Expr, Expr), + Expr(Expr), + If(Vec<(Expr, Vec)>, Option>), + While(Expr, Vec), + For { var: String, from: Expr, to: Expr, up: bool, step: Option, body: Vec }, + Foreach { var: String, list: Vec, body: Vec }, + Func { name: String, f: Rc }, + Return(Option), + Break, + Continue, + Exit, + Throw(Expr), +} diff --git a/crates/js/src/formcalc/builtins.rs b/crates/js/src/formcalc/builtins.rs new file mode 100644 index 0000000000..c3b7c7f1f2 --- /dev/null +++ b/crates/js/src/formcalc/builtins.rs @@ -0,0 +1,504 @@ +use super::ast::Expr; +use super::dates::{MAX_DAYS, civil_from_days, days_from_civil, format_date, parse_date}; +use super::parser::parse; +use super::value::{Val, fixed, is_numeric_text, parse_num}; +use super::words::word_num; +use super::{Interp, MAX_STRING, R, STRING_TOO_LONG, XfaKind, is_limit}; + +impl Interp<'_> { + /// A built-in function: its result is never a string past [`MAX_STRING`], and work on + /// long strings is charged to the step budget. + pub(super) fn builtin(&mut self, name: &str, args: &[Expr]) -> R { + let v = self.builtin_inner(name, args)?; + if let Val::Str(s) = &v { + if s.len() > MAX_STRING { + return Err(STRING_TOO_LONG.into()); + } + self.charge((s.len() / 256) as u64)?; + } + Ok(v) + } + + fn builtin_inner(&mut self, name: &str, args: &[Expr]) -> R { + let lower = name.to_ascii_lowercase(); + let n = |v: f64| Ok(Val::Num(v)); + if let Some(value) = self.date_builtin(&lower, args)? { + return Ok(value); + } + if let Some(value) = self.financial_builtin(&lower, args)? { + return Ok(value); + } + if let Some(value) = self.unit_builtin(&lower, args)? { + return Ok(value); + } + Ok(match lower.as_str() { + // ── arithmetic + "abs" => return n(self.arg_num(args, 0)?.abs()), + "ceil" => return n(self.arg_num(args, 0)?.ceil()), + "floor" => return n(self.arg_num(args, 0)?.floor()), + "mod" => { + let (a, b) = (self.arg_num(args, 0)?, self.arg_num(args, 1)?); + if b == 0.0 { + return Err("division by zero".into()); + } + Val::Num(a - b * (a / b).trunc()) + } + "round" => { + let a = self.arg_num(args, 0)?; + let places = if args.len() > 1 { self.arg_num(args, 1)?.clamp(0.0, 12.0) as i32 } else { 0 }; + let k = 10f64.powi(places); + let scaled = a * k; + if scaled.is_finite() { Val::Num(scaled.round() / k) } else { Val::Num(a) } + } + "count" => { + // Non-null values and objects: an empty field doesn't count, a subform does. + let vals = self.arg_values_keeping(args, true)?; + let counts = |v: &Val| match v { + Val::Node(i) => self.node(*i).is_some_and(|n| !matches!(n.kind, XfaKind::Field | XfaKind::ExclGroup) || !n.value.is_empty()), + other => !self.is_null(other), + }; + Val::Num(vals.iter().filter(|v| counts(v)).count() as f64) + } + "sum" | "avg" | "max" | "min" => { + let vals = self.arg_values(args)?; + let nums: Vec = vals.iter().map(|v| self.to_num(v)).collect(); + match lower.as_str() { + "sum" => Val::Num(nums.iter().sum()), + "avg" => { + if nums.is_empty() { + Val::Null + } else { + Val::Num(nums.iter().sum::() / nums.len() as f64) + } + } + "max" => nums.iter().copied().fold(None, |m: Option, x| Some(m.map_or(x, |m| m.max(x)))).map_or(Val::Null, Val::Num), + _ => nums.iter().copied().fold(None, |m: Option, x| Some(m.map_or(x, |m| m.min(x)))).map_or(Val::Null, Val::Num), + } + } + // ── logical + "choose" => { + let i = self.arg_num(args, 0)?; + if !i.is_finite() || i < 1.0 { + return Ok(Val::Null); + } + self.arg(args, i as usize)? + } + "exists" => { + let Some(e) = args.first() else { return Ok(Val::Num(0.0)) }; + // A name that resolves to nothing is the answer "no"; a limit the lookup ran + // into (time, steps, depth, size) still stops the script. + match self.all_of(e) { + Ok(v) => Val::Num(if v.is_empty() { 0.0 } else { 1.0 }), + Err(e) if is_limit(&e) => return Err(e), + Err(_) => Val::Num(0.0), + } + } + "hasvalue" => { + let v = self.arg(args, 0)?; + Val::Num(if self.is_null(&v) || matches!(&v, Val::Str(s) if s.trim().is_empty()) { 0.0 } else { 1.0 }) + } + "oneof" => { + let v = self.arg(args, 0)?; + let rest = self.arg_values(args.get(1..).unwrap_or_default())?; + let mut hit = false; + for x in &rest { + // Each comparison copies and compares `v`: charged per item. + self.charge(self.cost(&v).saturating_add(self.cost(x)).saturating_add(1))?; + if self.binary("==", v.clone(), x.clone()).is_ok_and(|r| self.truthy(&r)) { + hit = true; + break; + } + } + Val::Num(if hit { 1.0 } else { 0.0 }) + } + "within" => { + let v = self.arg(args, 0)?; + let (lo, hi) = (self.arg(args, 1)?, self.arg(args, 2)?); + let ok = self.binary(">=", v.clone(), lo).is_ok_and(|r| self.truthy(&r)) && self.binary("<=", v, hi).is_ok_and(|r| self.truthy(&r)); + Val::Num(if ok { 1.0 } else { 0.0 }) + } + // ── strings + "at" => { + let (s, sub) = (self.arg_str(args, 0)?, self.arg_str(args, 1)?); + Val::Num(if sub.is_empty() { 0.0 } else { s.find(&sub).map_or(0.0, |b| s[..b].chars().count() as f64 + 1.0) }) + } + "concat" => { + let mut out = String::new(); + for i in 0..args.len() { + out.push_str(&self.arg_str(args, i)?); + if out.len() > MAX_STRING { + return Err(STRING_TOO_LONG.into()); + } + } + Val::Str(out) + } + "left" => { + let (s, k) = (self.arg_str(args, 0)?, self.arg_num(args, 1)?.max(0.0) as usize); + Val::Str(s.chars().take(k).collect()) + } + "right" => { + let (s, k) = (self.arg_str(args, 0)?, self.arg_num(args, 1)?.max(0.0) as usize); + let len = s.chars().count(); + Val::Str(s.chars().skip(len.saturating_sub(k)).collect()) + } + "len" => Val::Num(self.arg_str(args, 0)?.chars().count() as f64), + "lower" => Val::Str(self.arg_str(args, 0)?.to_lowercase()), + "upper" => Val::Str(self.arg_str(args, 0)?.to_uppercase()), + "ltrim" => Val::Str(self.arg_str(args, 0)?.trim_start().to_string()), + "rtrim" => Val::Str(self.arg_str(args, 0)?.trim_end().to_string()), + "space" => { + let k = self.arg_num(args, 0)?.clamp(0.0, MAX_STRING as f64) as usize; + Val::Str(" ".repeat(k)) + } + "str" => { + let v = self.arg_num(args, 0)?; + let width = if args.len() > 1 { self.arg_num(args, 1)?.clamp(0.0, 1000.0) as usize } else { 10 }; + let prec = if args.len() > 2 { self.arg_num(args, 2)?.clamp(0.0, 20.0) as usize } else { 0 }; + let text = fixed(v, prec); + Val::Str(if text.len() > width { "*".repeat(width) } else { format!("{text:>width$}") }) + } + "substr" => { + let s = self.arg_str(args, 0)?; + let start = self.arg_num(args, 1)?.max(1.0) as usize; + let len = self.arg_num(args, 2)?.max(0.0) as usize; + Val::Str(s.chars().skip(start - 1).take(len).collect()) + } + "stuff" => { + let s = self.arg_str(args, 0)?; + let start = self.arg_num(args, 1)?.max(1.0) as usize; + let del = self.arg_num(args, 2)?.max(0.0) as usize; + let ins = if args.len() > 3 { self.arg_str(args, 3)? } else { String::new() }; + let chars: Vec = s.chars().collect(); + let a = start.saturating_sub(1).min(chars.len()); + let b = a.saturating_add(del).min(chars.len()); + let out: String = chars[..a].iter().chain(ins.chars().collect::>().iter()).chain(chars[b..].iter()).collect(); + if out.len() > MAX_STRING { + return Err(STRING_TOO_LONG.into()); + } + Val::Str(out) + } + "replace" => { + let (s, old) = (self.arg_str(args, 0)?, self.arg_str(args, 1)?); + let new = if args.len() > 2 { self.arg_str(args, 2)? } else { String::new() }; + if old.is_empty() { + return Ok(Val::Str(s)); + } + let grown = s.matches(old.as_str()).count().saturating_mul(new.len()).saturating_add(s.len()); + if grown > MAX_STRING { + return Err(STRING_TOO_LONG.into()); + } + Val::Str(s.replace(&old, &new)) + } + "wordnum" => { + let v = self.arg_num(args, 0)?; + let kind = if args.len() > 1 { self.arg_num(args, 1)? } else { 0.0 }; + Val::Str(word_num(v, kind == 2.0)) + } + "uuid" => Val::Str(format!("{:032x}", (self.steps as u128).wrapping_mul(0x9E37_79B9_7F4A_7C15_F39C_C060_5CED_C835))), + "encode" | "decode" => { + let s = self.arg_str(args, 0)?; + let kind = if args.len() > 1 { self.arg_str(args, 1)?.to_ascii_lowercase() } else { "url".into() }; + Val::Str(match (lower.as_str(), kind.as_str()) { + ("encode", "html" | "xml") => s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """), + ("decode", "html" | "xml") => s.replace("<", "<").replace(">", ">").replace(""", "\"").replace("&", "&"), + ("encode", _) => s + .bytes() + .map(|b| if b.is_ascii_alphanumeric() || b"-_.~".contains(&b) { (b as char).to_string() } else { format!("%{b:02X}") }) + .collect(), + (_, _) => { + let mut out = Vec::new(); + let b = s.as_bytes(); + let mut i = 0; + while i < b.len() { + if b[i] == b'%' + && let Some(h) = b + .get(i + 1..i + 3) + .filter(|h| h.iter().all(u8::is_ascii_hexdigit)) + .and_then(|h| std::str::from_utf8(h).ok()) + .and_then(|h| u8::from_str_radix(h, 16).ok()) + { + out.push(h); + i += 3; + } else { + out.push(b[i]); + i += 1; + } + } + String::from_utf8_lossy(&out).into_owned() + } + }) + } + "format" => { + // Number pictures: `9` digit, `z` optional digit, `,` separator, `.` point, `$`. + let pic = self.arg_str(args, 0)?; + let v = self.arg(args, 1)?; + if pic.to_ascii_uppercase().contains("YY") || pic.to_ascii_uppercase().contains("DD") { + let text = self.to_str(&v); + let days = parse_num(&text).filter(|d| is_numeric_text(&text) && d.abs() < MAX_DAYS).map(|d| d as i64); + return Ok(match days { + Some(d) => { + let (y, m, dd) = civil_from_days(d); + Val::Str(format_date(y, m, dd, &pic)) + } + None => parse_date(&text, "YYYY-MM-DD").map_or(Val::Str(text), |(y, m, d)| Val::Str(format_date(y, m, d, &pic))), + }); + } + let num = self.to_num(&v); + let decimals = pic.split_once('.').map_or(0, |(_, f)| f.chars().filter(|c| matches!(c, '9' | 'z' | 'Z')).count()).min(20); + // `9` in the integer part forces a digit; `z` does not. + let min_int = pic.split('.').next().map_or(0, |i| i.chars().filter(|c| *c == '9').count()).min(20); + let width = min_int + if decimals > 0 { decimals + 1 } else { 0 }; + let grouped = pic.contains(','); + let mut text = format!("{:0>width$}", fixed(num.abs(), decimals)); + if grouped { + let (int, frac) = text.split_once('.').map_or((text.as_str(), ""), |(a, b)| (a, b)); + let mut g = String::new(); + for (k, c) in int.chars().enumerate() { + if k > 0 && (int.len() - k) % 3 == 0 { + g.push(','); + } + g.push(c); + } + text = if frac.is_empty() { g } else { format!("{g}.{frac}") }; + } + let nonzero = text.chars().any(|c| ('1'..='9').contains(&c)); + if pic.contains('$') { + text = format!("${text}"); + } + if num < 0.0 && nonzero { + text = format!("-{text}"); + } + Val::Str(text) + } + "parse" => { + let pic = self.arg_str(args, 0)?; + let s = self.arg_str(args, 1)?; + if pic.to_ascii_uppercase().contains("YY") { + // The canonical form of a date is ISO. + return Ok(parse_date(&s, &pic).map_or(Val::Null, |(y, m, d)| Val::Str(format_date(y, m, d, "YYYY-MM-DD")))); + } + let cleaned: String = s.chars().filter(|c| c.is_ascii_digit() || *c == '.' || *c == '-').collect(); + parse_num(&cleaned).map_or(Val::Null, Val::Num) + } + "ref" => { + let Some(e) = args.first() else { return Ok(Val::Null) }; + self.eval(e)? + } + "eval" => { + let src = self.arg_str(args, 0)?; + self.charge((src.len() / 16) as u64)?; + let body = parse(&src)?; + let (_, v) = self.block(&body)?; + v + } + "get" | "post" | "put" => return Err(format!("{name}: network access is not available to form scripts")), + _ => return Err(format!("unknown function {name}")), + }) + } + + fn date_builtin(&mut self, lower: &str, args: &[Expr]) -> R> { + let value = match lower { + "date" => { + #[cfg(not(target_arch = "wasm32"))] + { + let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0); + Val::Num((secs.div_euclid(86_400) + 25_568) as f64) + } + #[cfg(target_arch = "wasm32")] + { + Val::Num(25_568.0) + } + } + "time" => { + #[cfg(not(target_arch = "wasm32"))] + { + let ms = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as i64).unwrap_or(0); + Val::Num(ms.rem_euclid(86_400_000) as f64) + } + #[cfg(target_arch = "wasm32")] + { + Val::Num(0.0) + } + } + "date2num" | "isodate2num" => { + let s = self.arg_str(args, 0)?; + let pic = if lower == "isodate2num" || args.len() < 2 { "YYYY-MM-DD".to_string() } else { self.arg_str(args, 1)? }; + let parsed = parse_date(&s, &pic).or_else(|| parse_date(&s, "YYYYMMDD")).or_else(|| parse_date(&s, "YYYY-MM-DD")); + parsed.map_or(Val::Num(0.0), |(y, m, d)| Val::Num(days_from_civil(y, m, d) as f64)) + } + "num2date" => { + let days = self.arg_num(args, 0)?; + let pic = if args.len() > 1 { self.arg_str(args, 1)? } else { "MMM D, YYYY".into() }; + if !days.is_finite() || days.abs() >= MAX_DAYS { + return Ok(Some(Val::Null)); + } + let (y, m, d) = civil_from_days(days as i64); + Val::Str(format_date(y, m, d, &pic)) + } + "datefmt" | "localdatefmt" => { + let style = if args.is_empty() { 0.0 } else { self.arg_num(args, 0)? }; + Val::Str( + match style as i32 { + 1 => "M/D/YY", + 2 => "MMM D, YYYY", + 3 => "MMMM D, YYYY", + 4 => "EEEE, MMMM D, YYYY", + _ => "MMM D, YYYY", + } + .into(), + ) + } + "timefmt" | "localtimefmt" => Val::Str("HH:MM:SS".into()), + "num2time" | "num2gmtime" => { + let ms = self.arg_num(args, 0)?; + if !ms.is_finite() || ms < 0.0 { + return Ok(Some(Val::Null)); + } + let s = (ms / 1000.0) as u64; + Val::Str(format!("{:02}:{:02}:{:02}", (s / 3600) % 24, (s / 60) % 60, s % 60)) + } + "time2num" | "isotime2num" => { + let s = self.arg_str(args, 0)?; + // ISO 8601 allows a leading "T", a trailing "Z" and fractional seconds. + let t = s.trim(); + let t = t.strip_prefix(['T', 't']).unwrap_or(t); + let t = t.strip_suffix(['Z', 'z']).unwrap_or(t); + let mut parts = t.split(':'); + let mut whole = || parts.next().and_then(|p| p.trim().parse::().ok()).unwrap_or(0).min(1_000_000); + let (h, m) = (whole(), whole()); + let sec = + parts.next().and_then(|p| p.trim().parse::().ok()).filter(|v| v.is_finite() && *v >= 0.0).unwrap_or(0.0).min(1_000_000.0); + Val::Num(((h * 3600 + m * 60) * 1000) as f64 + (sec * 1000.0).round()) + } + _ => return Ok(None), + }; + Ok(Some(value)) + } + + fn financial_builtin(&mut self, lower: &str, args: &[Expr]) -> R> { + let value = match lower { + "fv" => { + let (p, r, k) = (self.arg_num(args, 0)?, self.arg_num(args, 1)?, self.arg_num(args, 2)?); + Val::Num(if r == 0.0 { p * k } else { p * (((1.0 + r).powf(k) - 1.0) / r) }) + } + "pv" => { + let (p, r, k) = (self.arg_num(args, 0)?, self.arg_num(args, 1)?, self.arg_num(args, 2)?); + Val::Num(if r == 0.0 { p * k } else { p * ((1.0 - (1.0 + r).powf(-k)) / r) }) + } + "pmt" => { + let (pv, r, k) = (self.arg_num(args, 0)?, self.arg_num(args, 1)?, self.arg_num(args, 2)?); + if k <= 0.0 { + return Err("Pmt needs a positive number of periods".into()); + } + Val::Num(if r == 0.0 { pv / k } else { pv * r / (1.0 - (1.0 + r).powf(-k)) }) + } + "term" => { + let (p, r, fv) = (self.arg_num(args, 0)?, self.arg_num(args, 1)?, self.arg_num(args, 2)?); + if p <= 0.0 || r <= 0.0 || fv <= 0.0 { + return Err("Term needs positive arguments".into()); + } + Val::Num(((fv * r / p) + 1.0).ln() / (1.0 + r).ln()) + } + "cterm" => { + let (r, fv, pv) = (self.arg_num(args, 0)?, self.arg_num(args, 1)?, self.arg_num(args, 2)?); + if r <= 0.0 || fv <= 0.0 || pv <= 0.0 { + return Err("CTerm needs positive arguments".into()); + } + Val::Num((fv / pv).ln() / (1.0 + r).ln()) + } + "rate" => { + let (fv, pv, k) = (self.arg_num(args, 0)?, self.arg_num(args, 1)?, self.arg_num(args, 2)?); + if fv <= 0.0 || pv <= 0.0 || k <= 0.0 { + return Err("Rate needs positive arguments".into()); + } + Val::Num((fv / pv).powf(1.0 / k) - 1.0) + } + "npv" => { + let r = self.arg_num(args, 0)?; + let flows = self.arg_values(args.get(1..).unwrap_or_default())?; + Val::Num(flows.iter().enumerate().map(|(i, v)| self.to_num(v) / (1.0 + r).powi(i as i32 + 1)).sum()) + } + "ipmt" | "ppmt" => { + let (pv, r, pmt, first, count) = ( + self.arg_num(args, 0)?, + self.arg_num(args, 1)?, + self.arg_num(args, 2)?, + self.arg_num(args, 3)?.max(1.0), + self.arg_num(args, 4)?.max(1.0), + ); + let (mut balance, mut interest, mut principal) = (pv, 0.0, 0.0); + for period in 1..=((first + count - 1.0).min(10_000.0) as u32) { + self.step()?; + let i = balance * r; + let p = (pmt - i).min(balance); + if period >= first as u32 { + interest += i; + principal += p; + } + balance -= p; + if balance <= 0.0 { + break; + } + } + Val::Num(if lower == "ipmt" { interest } else { principal }) + } + "apr" => { + let (principal, payment, periods) = (self.arg_num(args, 0)?, self.arg_num(args, 1)?, self.arg_num(args, 2)?); + if principal <= 0.0 || payment <= 0.0 || periods <= 0.0 { + return Err("Apr needs positive arguments".into()); + } + // Bisection on the monthly rate. + let (mut lo, mut hi) = (0.0f64, 1.0f64); + for _ in 0..200 { + let mid = (lo + hi) / 2.0; + let pmt = if mid == 0.0 { principal / periods } else { principal * mid / (1.0 - (1.0 + mid).powf(-periods)) }; + if pmt > payment { + hi = mid; + } else { + lo = mid; + } + } + Val::Num((lo + hi) / 2.0 * 12.0) + } + _ => return Ok(None), + }; + Ok(Some(value)) + } + + fn unit_builtin(&mut self, lower: &str, args: &[Expr]) -> R> { + let value = match lower { + "unittype" => { + let s = self.arg_str(args, 0)?; + let unit: String = s.trim().chars().skip_while(|c| c.is_ascii_digit() || *c == '.' || *c == '-' || *c == '+').collect(); + Val::Str( + match unit.trim().to_ascii_lowercase().as_str() { + "in" | "inch" | "inches" => "in", + "mm" | "millimeter" | "millimeters" => "mm", + "cm" | "centimeter" | "centimeters" => "cm", + "pt" | "point" | "points" => "pt", + "pc" | "pica" => "pc", + _ => "in", + } + .into(), + ) + } + "unitvalue" => { + let s = self.arg_str(args, 0)?; + let to = if args.len() > 1 { self.arg_str(args, 1)? } else { String::new() }; + let v = parse_num(&s).unwrap_or(0.0); + let unit: String = s.trim().chars().skip_while(|c| c.is_ascii_digit() || *c == '.' || *c == '-' || *c == '+').collect(); + let per_in = |u: &str| match u.trim().to_ascii_lowercase().as_str() { + "mm" | "millimeter" | "millimeters" => 25.4, + "cm" | "centimeter" | "centimeters" => 2.54, + "pt" | "point" | "points" => 72.0, + "pc" | "pica" => 6.0, + _ => 1.0, + }; + let target = if to.trim().is_empty() { unit.clone() } else { to }; + Val::Num(v / per_in(&unit) * per_in(&target)) + } + _ => return Ok(None), + }; + Ok(Some(value)) + } +} diff --git a/crates/js/src/formcalc/dates.rs b/crates/js/src/formcalc/dates.rs new file mode 100644 index 0000000000..bfb6383cdd --- /dev/null +++ b/crates/js/src/formcalc/dates.rs @@ -0,0 +1,166 @@ +// ── built-in functions ────────────────────────────────────────────────────────────────────── + +/// `NaiveDate::num_days_from_ce` of 1900-01-01 minus one: a civil date's day count rebased so +/// that 1900-01-01 is FormCalc day 1 (and 1970-01-01 is day 25_568). +const FORMCALC_EPOCH_FROM_CE: i64 = 693_595; + +/// Days in one 400-year Gregorian cycle; the calendar repeats after it. +const DAYS_PER_400_YEARS: i64 = 146_097; + +/// Days from 1900-01-01 (day 1, as FormCalc counts) to a civil date. +/// +/// chrono's `NaiveDate` stops near ±262143, but FormCalc accepts day numbers up to `MAX_DAYS` +/// (years past 270000). The calendar repeats every 400 years, so chrono converts the date +/// within its cycle and whole cycles are added back; the result matches the proleptic Gregorian +/// count for every year. Callers pass dates `parse_date`/`civil_from_days` already validated; +/// anything else reads as 0. +pub(super) fn days_from_civil(y: i64, m: u32, d: u32) -> i64 { + use chrono::Datelike; + let (cycles, year) = (i128::from(y.div_euclid(400)), y.rem_euclid(400)); + i32::try_from(year) + .ok() + .and_then(|year| chrono::NaiveDate::from_ymd_opt(year, m, d)) + .and_then(|date| { + let days = i128::from(date.num_days_from_ce()) - i128::from(FORMCALC_EPOCH_FROM_CE) + cycles * i128::from(DAYS_PER_400_YEARS); + i64::try_from(days).ok() + }) + .unwrap_or(0) +} + +/// The civil date (year, month, day) at FormCalc day number `n` (day 1 is 1900-01-01), for any +/// `n`: chrono converts the day within its 400-year cycle and the cycles are added back as years. +pub(super) fn civil_from_days(n: i64) -> (i64, u32, u32) { + use chrono::Datelike; + // Days since CE day 0 (0000-12-31), in i128 so no `n` overflows. + let from_ce = i128::from(n) + i128::from(FORMCALC_EPOCH_FROM_CE); + let (cycles, rest) = (from_ce.div_euclid(i128::from(DAYS_PER_400_YEARS)), from_ce.rem_euclid(i128::from(DAYS_PER_400_YEARS))); + // `rest` is 0..146_097, so CE day `rest` lies in years 0..=400 (day 0 is 0000-12-31). + let date = i32::try_from(rest).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt); + match date { + Some(date) => { + let y = i128::from(date.year()) + cycles * 400; + (i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), date.month(), date.day()) + } + // Unreachable: every `rest` in 0..146_097 is a valid CE day. + None => (1900, 1, 1), + } +} + +/// Day numbers (and years) beyond this are refused rather than computed. +pub(super) const MAX_DAYS: f64 = 1e8; + +const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; + +/// Parse `text` as a date by `picture` (`YYYY-MM-DD`, `MM/DD/YYYY`, `MMM D, YYYY`, …). +pub(super) fn parse_date(text: &str, picture: &str) -> Option<(i64, u32, u32)> { + let pic: Vec = picture.chars().collect(); + let txt: Vec = text.trim().chars().collect(); + let (mut y, mut m, mut d) = (None, None, None); + let (mut i, mut j) = (0, 0); + while i < pic.len() { + let c = pic[i].to_ascii_uppercase(); + if c == 'Y' || c == 'M' || c == 'D' { + let mut run = 1; + while i + run < pic.len() && pic[i + run].to_ascii_uppercase() == c { + run += 1; + } + i += run; + if c == 'M' && run >= 3 { + // Month name. + let rest: String = txt.get(j..).unwrap_or(&[]).iter().collect(); + let lower = rest.to_ascii_lowercase(); + let found = MONTHS + .iter() + .position(|name| lower.starts_with(&name.to_ascii_lowercase()) || lower.starts_with(&name[..3].to_ascii_lowercase())); + let k = found?; + m = Some(k as u32 + 1); + let full = MONTHS[k].to_ascii_lowercase(); + j += if lower.starts_with(&full) { full.chars().count() } else { 3 }; + continue; + } + let width = if run == 1 { 2 } else { run }; + let mut digits = String::new(); + while j < txt.len() && txt[j].is_ascii_digit() && digits.len() < width { + digits.push(txt[j]); + j += 1; + } + if digits.is_empty() { + return None; + } + let v: i64 = digits.parse().ok()?; + match c { + 'Y' => y = Some(if run <= 2 { if v < 50 { 2000 + v } else { 1900 + v } } else { v }), + 'M' => m = Some(v as u32), + _ => d = Some(v as u32), + } + } else { + if txt.get(j) != Some(&pic[i]) { + return None; + } + i += 1; + j += 1; + } + } + let (y, m, d) = (y?, m.unwrap_or(1), d.unwrap_or(1)); + ((-200_000..=200_000).contains(&y) && (1..=12).contains(&m) && (1..=days_in(y, m)).contains(&d)).then_some((y, m, d)) +} + +/// Number of days in month `m` (1-12) of year `y`, leap years included. +pub(crate) fn days_in(y: i64, m: u32) -> u32 { + // The length of the month from chrono (the first of the next month, less the first of this + // one). An invalid month keeps the old 31-day default. + // Month lengths repeat every 400 years, so the year within its cycle is enough (and always + // in chrono's range, whatever `y` is). + let y = y.rem_euclid(400); + let first = |y: i64, m: u32| i32::try_from(y).ok().and_then(|y| chrono::NaiveDate::from_ymd_opt(y, m, 1)); + let next = if m == 12 { first(y + 1, 1) } else { first(y, m + 1) }; + match (first(y, m), next) { + (Some(a), Some(b)) => (b - a).num_days() as u32, + _ => 31, + } +} + +const WEEKDAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; + +pub(super) fn format_date(y: i64, m: u32, d: u32, picture: &str) -> String { + let pic: Vec = picture.chars().collect(); + let mut out = String::new(); + let mut i = 0; + while i < pic.len() { + let c = pic[i].to_ascii_uppercase(); + if c == 'Y' || c == 'M' || c == 'D' { + let mut run = 1; + while i + run < pic.len() && pic[i + run].to_ascii_uppercase() == c { + run += 1; + } + i += run; + match (c, run) { + ('Y', 1 | 2) => out.push_str(&format!("{:02}", y.rem_euclid(100))), + ('Y', _) => out.push_str(&format!("{y:04}")), + ('M', 1) => out.push_str(&m.to_string()), + ('M', 2) => out.push_str(&format!("{m:02}")), + ('M', 3) => out.push_str(MONTHS.get((m as usize).saturating_sub(1)).map_or("", |n| &n[..3])), + ('M', _) => out.push_str(MONTHS.get((m as usize).saturating_sub(1)).copied().unwrap_or("")), + ('D', 1) => out.push_str(&d.to_string()), + (_, _) => out.push_str(&format!("{d:02}")), + } + } else if pic[i] == 'E' { + let mut run = 1; + while i + run < pic.len() && pic[i + run] == 'E' { + run += 1; + } + i += run; + // Day 1 (1900-01-01) was a Monday, so the serial modulo 7 indexes from Sunday. + let weekday = days_from_civil(y, m, d).rem_euclid(7) as usize; + match run { + 1 | 2 => out.push_str(&(weekday + 1).to_string()), + 3 => out.push_str(WEEKDAYS.get(weekday).map_or("", |n| &n[..3])), + _ => out.push_str(WEEKDAYS.get(weekday).copied().unwrap_or("")), + } + } else { + out.push(pic[i]); + i += 1; + } + } + out +} diff --git a/crates/js/src/formcalc/eval.rs b/crates/js/src/formcalc/eval.rs new file mode 100644 index 0000000000..0b2dfe4b94 --- /dev/null +++ b/crates/js/src/formcalc/eval.rs @@ -0,0 +1,629 @@ +use super::ast::{Expr, Stmt}; +use super::value::{Val, is_numeric_text, num_text}; +use super::{ + CALLS_TOO_DEEP, Flow, Func, HashMap, HashSet, Interp, LIST_TOO_LONG, LOOPED_TOO_MUCH, MAX_LIST, Message, MessageButtons, MessageIcon, R, Rc, + TOO_DEEP, XfaEffect, child_named, clip, descendants_named, instances, reset_names, resolve_som, +}; + +impl Interp<'_> { + // ── expressions + + pub(super) fn eval(&mut self, e: &Expr) -> R { + self.step()?; + self.depth += 1; + if self.depth > self.max_depth { + self.depth -= 1; + return Err(TOO_DEEP.into()); + } + let r = self.eval_inner(e); + self.depth -= 1; + r + } + + fn eval_inner(&mut self, e: &Expr) -> R { + Ok(match e { + Expr::Num(n) => Val::Num(*n), + Expr::Str(s) => { + self.charge((s.len() / 256) as u64)?; + Val::Str(s.clone()) + } + Expr::Null => Val::Null, + Expr::Ident(name) => { + let v = self.ident(name)?; + self.charge(self.cost(&v))?; + v + } + Expr::Member(base, name) => { + let b = self.eval(base)?; + self.member(b, name)? + } + Expr::Index(base, idx) => self.index(base, idx)?, + Expr::Descend(base, name) => { + let b = self.eval(base)?; + let mut out = Vec::new(); + let mut seen = HashSet::new(); + for i in self.nodes_list(&b) { + self.gather(&mut out, &mut seen, descendants_named(self.h, i, name, 0))?; + } + match out.as_slice() { + [] => Val::Null, + [one] => Val::Node(*one), + many => Val::List(many.iter().map(|k| Val::Node(*k)).collect()), + } + } + Expr::Call(callee, args) => self.call(callee, args)?, + Expr::Neg(x) => { + let v = self.eval(x)?; + if self.is_null(&v) { Val::Null } else { Val::Num(-self.to_num(&v)) } + } + Expr::Not(x) => { + let v = self.eval(x)?; + Val::Num(if self.truthy(&v) { 0.0 } else { 1.0 }) + } + Expr::Bin(..) => { + // The parser leans left (`a + b + c` is `(a + b) + c`), so a chain is walked + // from its leftmost operand and folded without recursing per operator. + let mut links = Vec::new(); + let mut leftmost = e; + while let Expr::Bin(op, l, r) = leftmost { + links.push((*op, r.as_ref())); + leftmost = l; + } + let mut acc = self.eval(leftmost)?; + for (op, r) in links.into_iter().rev() { + self.step()?; + acc = match op { + "&" => { + if !self.truthy(&acc) { + // Not evaluated: the result is settled. + Val::Num(0.0) + } else { + let b = self.eval(r)?; + Val::Num(if self.truthy(&b) { 1.0 } else { 0.0 }) + } + } + "|" => { + if self.truthy(&acc) { + Val::Num(1.0) + } else { + let b = self.eval(r)?; + Val::Num(if self.truthy(&b) { 1.0 } else { 0.0 }) + } + } + _ => { + let b = self.eval(r)?; + // Comparing (and dereferencing) long strings is work too. + self.charge(self.cost(&acc).saturating_add(self.cost(&b)))?; + self.binary(op, acc, b)? + } + }; + } + acc + } + }) + } + + pub(super) fn binary(&self, op: &str, a: Val, b: Val) -> R { + let (a, b) = (self.deref(a), self.deref(b)); + // Null with an arithmetic operator is null only when both sides are null. + match op { + "+" | "-" | "*" | "/" => { + if self.is_null(&a) && self.is_null(&b) { + return Ok(Val::Null); + } + let (x, y) = (self.to_num(&a), self.to_num(&b)); + let v = match op { + "+" => x + y, + "-" => x - y, + "*" => x * y, + _ => { + if y == 0.0 { + return Err("division by zero".into()); + } + x / y + } + }; + Ok(Val::Num(v)) + } + _ => { + let ord = match (&a, &b) { + (Val::Null, Val::Null) => Some(std::cmp::Ordering::Equal), + (Val::Null, _) | (_, Val::Null) => None, + (Val::Str(s), Val::Str(t)) if !(is_numeric_text(s) && is_numeric_text(t)) => Some(s.cmp(t)), + (Val::Str(s), Val::Num(_)) if !is_numeric_text(s) => Some(s.as_str().cmp(num_text(self.to_num(&b)).as_str())), + (Val::Num(_), Val::Str(t)) if !is_numeric_text(t) => Some(num_text(self.to_num(&a)).as_str().cmp(t.as_str())), + _ => self.to_num(&a).partial_cmp(&self.to_num(&b)), + }; + let r = match (op, ord) { + ("==", Some(o)) => o.is_eq(), + ("<>", Some(o)) => !o.is_eq(), + ("<>", None) => true, + ("<", Some(o)) => o.is_lt(), + ("<=", Some(o)) => o.is_le(), + (">", Some(o)) => o.is_gt(), + (">=", Some(o)) => o.is_ge(), + _ => false, + }; + Ok(Val::Num(if r { 1.0 } else { 0.0 })) + } + } + } + + // ── calls + + fn call(&mut self, callee: &Expr, args: &[Expr]) -> R { + match callee { + Expr::Ident(name) => { + if let Some(f) = self.funcs.get(&name.to_ascii_lowercase()).cloned() { + let mut vals = Vec::new(); + for a in args { + let v = self.eval(a)?; + vals.push(self.take(v)?); + } + return self.call_user(&f, vals); + } + self.builtin(name, args) + } + Expr::Member(base, method) => { + let b = self.eval(base)?; + self.method(b, method, args) + } + _ => Err("not a function".into()), + } + } + + fn call_user(&mut self, f: &Func, vals: Vec) -> R { + if self.scopes.len() > self.max_depth { + return Err(CALLS_TOO_DEEP.into()); + } + let mut scope = HashMap::new(); + for (i, p) in f.params.iter().enumerate() { + scope.insert(p.clone(), vals.get(i).cloned().unwrap_or(Val::Null)); + } + self.scopes.push(scope); + let r = self.block(&f.body); + self.scopes.pop(); + match r? { + (Flow::Return(v), _) => Ok(v), + (Flow::Exit, last) => { + self.exited = true; + Ok(last) + } + (_, last) => Ok(last), + } + } + + pub(super) fn arg(&mut self, args: &[Expr], i: usize) -> R { + match args.get(i) { + Some(e) => { + let v = self.eval(e)?; + self.take(v) + } + None => Ok(Val::Null), + } + } + + pub(super) fn arg_num(&mut self, args: &[Expr], i: usize) -> R { + let v = self.arg(args, i)?; + Ok(self.to_num(&v)) + } + + pub(super) fn arg_str(&mut self, args: &[Expr], i: usize) -> R { + let v = self.arg(args, i)?; + Ok(self.to_str(&v)) + } + + /// Every value the arguments hold, lists flattened, nulls left out. + pub(super) fn arg_values(&mut self, args: &[Expr]) -> R> { + self.arg_values_keeping(args, false) + } + + /// [`Self::arg_values`]; `objects` keeps form objects (fields, subforms) as themselves, so + /// `Count(row[*])` counts rows rather than filled-in values. + pub(super) fn arg_values_keeping(&mut self, args: &[Expr], objects: bool) -> R> { + let mut out = Vec::new(); + for a in args { + let v = self.eval(a)?; + let v = if objects { v } else { self.take(v)? }; + match v { + Val::List(items) => out.extend(items.into_iter().filter(|x| !matches!(x, Val::Null))), + Val::Null => {} + other => out.push(other), + } + if out.len() > MAX_LIST { + return Err(LIST_TOO_LONG.into()); + } + } + Ok(out) + } + + /// Call `method` on `base` (`$host`, a form object, an instance manager…) with `args`. + fn method(&mut self, base: Val, method: &str, args: &[Expr]) -> R { + let m = method.to_ascii_lowercase(); + match base { + Val::Host => match m.as_str() { + "messagebox" => { + // `$host.messageBox(message, title, icon, buttons)`; a missing number is 0, + // the default icon and buttons. + let text = self.arg_str(args, 0)?; + let title = Some(self.arg_str(args, 1)?).filter(|t| !t.is_empty()); + let icon = MessageIcon::from_code(Some(self.arg_num(args, 2)?)); + let buttons = MessageButtons::from_code(Some(self.arg_num(args, 3)?)); + self.h.push(XfaEffect::MessageBox(Message { text, title, icon, buttons })); + // Always OK for now: the viewer can't ask the user and run the script on + // with the answer yet (#4291). + Ok(Val::Num(1.0)) + } + "resetdata" => { + let list = self.arg_str(args, 0)?; + self.h.push(XfaEffect::ResetData(reset_names(&list))); + Ok(Val::Null) + } + "print" => { + self.h.push(XfaEffect::Print); + Ok(Val::Null) + } + "beep" => { + self.h.push(XfaEffect::Beep); + Ok(Val::Null) + } + "gotourl" => { + let u = self.arg_str(args, 0)?; + self.h.push(XfaEffect::LaunchUrl(u)); + Ok(Val::Null) + } + "setfocus" => { + let target = match args.first() { + Some(e) => { + let raw = self.eval(e)?; + match self.nodes_of(&raw).first() { + Some(i) => self.node(*i).map(|n| n.som.clone()).unwrap_or_default(), + None => { + let s = self.to_str(&raw); + let found = resolve_som(self.h, self.h.current, &s); + found.first().and_then(|i| self.node(*i)).map(|n| n.som.clone()).unwrap_or(s) + } + } + } + None => String::new(), + }; + self.h.push(XfaEffect::SetFocus(target)); + Ok(Val::Null) + } + _ => Ok(Val::Null), + }, + Val::Layout => match m.as_str() { + "page" | "abspage" => Ok(Val::Num(self.h.doc.page as f64 + if m == "page" { 1.0 } else { 0.0 })), + "pagecount" | "abspagecount" => Ok(Val::Num(self.h.doc.page_count as f64)), + "relayout" | "relayoutpagearea" => { + self.h.push(XfaEffect::Relayout); + Ok(Val::Null) + } + _ => Ok(Val::Num(0.0)), + }, + Val::Xfa => match m.as_str() { + "resolvenode" | "resolvenodes" => { + let s = self.arg_str(args, 0)?; + let found = resolve_som(self.h, self.h.current, &s); + Ok(self.set_val(found, m == "resolvenodes")) + } + "recalculate" => { + self.h.push(XfaEffect::Recalculate); + Ok(Val::Null) + } + "remerge" => { + self.h.push(XfaEffect::Relayout); + Ok(Val::Null) + } + _ => Ok(Val::Null), + }, + Val::Node(i) => match m.as_str() { + "resolvenode" | "resolvenodes" => { + let s = self.arg_str(args, 0)?; + let found = resolve_som(self.h, i, &s); + Ok(self.set_val(found, m == "resolvenodes")) + } + "recalculate" => { + self.h.push(XfaEffect::Recalculate); + Ok(Val::Null) + } + "remerge" => { + self.h.push(XfaEffect::Relayout); + Ok(Val::Null) + } + _ => Ok(Val::Null), + }, + Val::Im(parent, name) => match m.as_str() { + "addinstance" | "insertinstance" => Ok(self.h.add_instance(parent, &name).map_or(Val::Null, Val::Node)), + "removeinstance" => { + let i = self.arg_num(args, 0)?; + if i.is_finite() && i >= 0.0 { + self.h.remove_instance(parent, &name, i as usize); + } + Ok(Val::Null) + } + "setinstances" => { + let want = self.arg_num(args, 0)?; + if want.is_finite() && want >= 0.0 { + self.h.set_instances(parent, &name, want as usize); + } + Ok(Val::Null) + } + _ => Ok(Val::Null), + }, + Val::Event | Val::Sink | Val::List(_) => Ok(Val::Null), + Val::Null => Err(format!("no object to call {method} on")), + Val::Num(_) | Val::Str(_) => Err(format!("{} has no method {method}", self.to_str(&base))), + } + } + + fn set_val(&self, found: Vec, many: bool) -> Val { + if many { + return Val::List(found.into_iter().map(Val::Node).collect()); + } + match found.as_slice() { + [] => Val::Null, + [one] => Val::Node(*one), + all => Val::List(all.iter().map(|i| Val::Node(*i)).collect()), + } + } + + // ── assignment + + fn assign(&mut self, target: &Expr, v: Val) -> R<()> { + let v = self.deref(v); + match target { + Expr::Ident(name) => { + if self.set_var(name, v.clone()) { + return Ok(()); + } + let t = self.ident(name)?; + self.assign_to(t, "rawValue", v) + } + Expr::Member(base, prop) => { + let b = self.eval(base)?; + self.assign_to(b, prop, v) + } + Expr::Index(..) | Expr::Descend(..) => { + let t = self.eval(target)?; + self.assign_to(t, "rawValue", v) + } + _ => Err("cannot assign to that".into()), + } + } + + fn assign_to(&mut self, target: Val, prop: &str, v: Val) -> R<()> { + match target { + Val::Node(i) => { + let Some(n) = self.node(i) else { return Ok(()) }; + let som = n.som.clone(); + match prop { + "rawValue" | "value" | "formattedValue" => { + let text = self.to_str(&v); + if !self.h.set_value(i, text) { + return Err(format!("{} is not a field", if som.is_empty() { "the form" } else { som.as_str() })); + } + } + "presence" => { + let p = self.to_str(&v); + if matches!(p.as_str(), "visible" | "invisible" | "hidden" | "inactive") { + self.h.set_state(i, XfaEffect::SetPresence { som, presence: p }); + } + } + "access" => { + let a = self.to_str(&v); + if matches!(a.as_str(), "open" | "readOnly" | "protected" | "nonInteractive") { + self.h.set_state(i, XfaEffect::SetAccess { som, access: a }); + } + } + other => { + // `page1.qty = 7`: the child object named by the last step takes the value. + if let Some(child) = other.strip_prefix('_') { + if child_named(self.h, i, child).is_some() { + return self.assign_to(Val::Im(i, child.to_string()), "count", v); + } + return Ok(()); + } + let kids = instances(self.h, i, other); + if kids.is_empty() { + return Err(format!("{som} has no {other} to assign to")); + } + for k in kids { + // Every destination copies the value: charge each copy. + self.charge(self.cost(&v))?; + self.assign_to(Val::Node(k), "rawValue", v.clone())?; + } + } + } + Ok(()) + } + Val::List(items) => { + for it in items { + self.charge(self.cost(&v))?; + self.assign_to(it, prop, v.clone())?; + } + Ok(()) + } + Val::Im(parent, name) if prop.eq_ignore_ascii_case("count") => { + let want = self.to_num(&v); + if want.is_finite() && want >= 0.0 { + self.h.set_instances(parent, &name, want as usize); + } + Ok(()) + } + _ => Ok(()), + } + } + + // ── statements + + /// Run a block; returns how it ended and the value of its last expression statement. + pub(super) fn block(&mut self, body: &[Stmt]) -> R<(Flow, Val)> { + // Nesting of statements counts like nesting of expressions: both are stack. + self.depth += 1; + if self.depth > self.max_depth { + self.depth -= 1; + return Err(TOO_DEEP.into()); + } + let r = self.block_inner(body); + self.depth -= 1; + r + } + + fn block_inner(&mut self, body: &[Stmt]) -> R<(Flow, Val)> { + let mut last = Val::Null; + for s in body { + self.step()?; + match self.exec(s)? { + (Flow::Next, v) => { + if let Some(v) = v { + last = v; + } + if self.exited { + return Ok((Flow::Exit, last)); + } + } + (flow, _) => return Ok((flow, last)), + } + } + Ok((Flow::Next, last)) + } + + fn exec(&mut self, s: &Stmt) -> R<(Flow, Option)> { + match s { + Stmt::Var(name, init) => { + let v = match init { + Some(e) => { + let v = self.eval(e)?; + self.take(v)? + } + None => Val::Null, + }; + self.declare(name, v.clone()); + Ok((Flow::Next, Some(v))) + } + Stmt::Assign(target, e) => { + let v = self.eval(e)?; + let v = self.take(v)?; + self.assign(target, v.clone())?; + Ok((Flow::Next, Some(v))) + } + Stmt::Expr(e) => { + let v = self.eval(e)?; + let v = self.take(v)?; + Ok((Flow::Next, Some(v))) + } + Stmt::If(arms, otherwise) => { + for (cond, body) in arms { + let c = self.eval(cond)?; + if self.truthy(&c) { + let (f, v) = self.block(body)?; + return Ok((f, Some(v))); + } + } + if let Some(body) = otherwise { + let (f, v) = self.block(body)?; + return Ok((f, Some(v))); + } + Ok((Flow::Next, None)) + } + Stmt::While(cond, body) => { + loop { + let c = self.eval(cond)?; + if !self.truthy(&c) { + break; + } + self.iterate()?; + match self.block(body)? { + (Flow::Break, _) => break, + (Flow::Return(v), _) => return Ok((Flow::Return(v), None)), + (Flow::Exit, _) => return Ok((Flow::Exit, None)), + _ => {} + } + } + Ok((Flow::Next, None)) + } + Stmt::For { var, from, to, up, step, body } => { + let start = { + let v = self.eval(from)?; + self.to_num(&v) + }; + let end = { + let v = self.eval(to)?; + self.to_num(&v) + }; + let by = match step { + Some(e) => { + let v = self.eval(e)?; + self.to_num(&v).abs() + } + None => 1.0, + }; + if by.is_nan() || by <= 0.0 || !start.is_finite() || !end.is_finite() { + return Err("bad for loop bounds".into()); + } + self.declare(var, Val::Num(start)); + let mut i = start; + while if *up { i <= end } else { i >= end } { + self.iterate()?; + self.set_var(var, Val::Num(i)); + match self.block(body)? { + (Flow::Break, _) => break, + (Flow::Return(v), _) => return Ok((Flow::Return(v), None)), + (Flow::Exit, _) => return Ok((Flow::Exit, None)), + _ => {} + } + i = if *up { i + by } else { i - by }; + } + Ok((Flow::Next, None)) + } + Stmt::Foreach { var, list, body } => { + let items = self.arg_values_keeping(list, true)?; + self.declare(var, Val::Null); + for it in items { + self.iterate()?; + self.set_var(var, it); + match self.block(body)? { + (Flow::Break, _) => break, + (Flow::Return(v), _) => return Ok((Flow::Return(v), None)), + (Flow::Exit, _) => return Ok((Flow::Exit, None)), + _ => {} + } + } + Ok((Flow::Next, None)) + } + Stmt::Func { name, f } => { + self.funcs.insert(name.to_ascii_lowercase(), Rc::clone(f)); + Ok((Flow::Next, None)) + } + Stmt::Return(e) => { + let v = match e { + Some(e) => { + let v = self.eval(e)?; + self.take(v)? + } + None => Val::Null, + }; + Ok((Flow::Return(v), None)) + } + Stmt::Break => Ok((Flow::Break, None)), + Stmt::Continue => Ok((Flow::Continue, None)), + Stmt::Exit => Ok((Flow::Exit, None)), + Stmt::Throw(e) => { + let v = self.eval(e)?; + self.charge(self.cost(&v))?; + // The thrown value becomes the error message the caller keeps: cut it as + // messages are. + Err(clip(self.to_str(&v))) + } + } + } + + fn iterate(&mut self) -> R<()> { + if self.loops_left == 0 { + return Err(LOOPED_TOO_MUCH.into()); + } + self.loops_left -= 1; + Ok(()) + } +} diff --git a/crates/js/src/formcalc/lexer.rs b/crates/js/src/formcalc/lexer.rs new file mode 100644 index 0000000000..46af19f761 --- /dev/null +++ b/crates/js/src/formcalc/lexer.rs @@ -0,0 +1,129 @@ +use super::MAX_STRING; + +// ── lexer ─────────────────────────────────────────────────────────────────────────────────── + +#[derive(Clone, Debug, PartialEq)] +pub(super) enum Tok { + Num(f64), + Str(String), + Ident(String), + Op(&'static str), + Newline, + Eof, +} + +#[derive(Clone, Debug, PartialEq)] +pub(super) struct Token { + pub(super) tok: Tok, + pub(super) line: usize, +} + +const OPS: [&str; 20] = ["<=", ">=", "==", "<>", "..", "(", ")", "[", "]", ".", ",", "=", "<", ">", "+", "-", "*", "/", "&", "|"]; + +pub(super) fn lex(src: &str) -> Result, String> { + let chars: Vec = src.chars().collect(); + let mut out = Vec::new(); + let mut i = 0; + let mut line = 1; + let mut depth = 0usize; + while i < chars.len() { + let c = chars[i]; + match c { + '\n' => { + line += 1; + i += 1; + // Newlines inside brackets continue the expression. + if depth == 0 { + out.push(Token { tok: Tok::Newline, line }); + } + } + ' ' | '\t' | '\r' => i += 1, + ';' => { + while i < chars.len() && chars[i] != '\n' { + i += 1; + } + } + '/' if chars.get(i + 1) == Some(&'/') => { + while i < chars.len() && chars[i] != '\n' { + i += 1; + } + } + '"' => { + let mut s = String::new(); + i += 1; + loop { + match chars.get(i) { + None => return Err(format!("line {line}: unterminated string")), + Some('"') if chars.get(i + 1) == Some(&'"') => { + s.push('"'); + i += 2; + } + Some('"') => { + i += 1; + break; + } + Some(&ch) => { + if ch == '\n' { + line += 1; + } + s.push(ch); + i += 1; + } + } + if s.len() > MAX_STRING { + return Err(format!("line {line}: a string is too long")); + } + } + out.push(Token { tok: Tok::Str(s), line }); + } + c if c.is_ascii_digit() || (c == '.' && chars.get(i + 1).is_some_and(char::is_ascii_digit)) => { + let start = i; + while i < chars.len() && (chars[i].is_ascii_digit() || (chars[i] == '.' && chars.get(i + 1) != Some(&'.'))) { + i += 1; + } + if i < chars.len() && (chars[i] == 'e' || chars[i] == 'E') { + let mut j = i + 1; + if j < chars.len() && (chars[j] == '+' || chars[j] == '-') { + j += 1; + } + if j < chars.len() && chars[j].is_ascii_digit() { + i = j; + while i < chars.len() && chars[i].is_ascii_digit() { + i += 1; + } + } + } + let text: String = chars[start..i].iter().collect(); + let n: f64 = text.parse().map_err(|_| format!("line {line}: bad number {text}"))?; + out.push(Token { tok: Tok::Num(n), line }); + } + '!' => { + out.push(Token { tok: Tok::Ident("!".into()), line }); + i += 1; + } + c if c.is_alphabetic() || c == '_' || c == '$' => { + let start = i; + i += 1; + while i < chars.len() && (chars[i].is_alphanumeric() || chars[i] == '_' || chars[i] == '$') { + i += 1; + } + let text: String = chars[start..i].iter().collect(); + out.push(Token { tok: Tok::Ident(text), line }); + } + _ => { + let rest: String = chars[i..chars.len().min(i + 2)].iter().collect(); + let op = OPS.iter().find(|o| rest.starts_with(*o)).copied().ok_or_else(|| format!("line {line}: unexpected character {c:?}"))?; + match op { + "(" | "[" => depth += 1, + ")" | "]" => depth = depth.saturating_sub(1), + _ => {} + } + out.push(Token { tok: Tok::Op(op), line }); + i += op.len(); + } + } + } + out.push(Token { tok: Tok::Newline, line }); + out.push(Token { tok: Tok::Eof, line }); + Ok(out) +} diff --git a/crates/js/src/formcalc/parser.rs b/crates/js/src/formcalc/parser.rs new file mode 100644 index 0000000000..fceedadf5f --- /dev/null +++ b/crates/js/src/formcalc/parser.rs @@ -0,0 +1,451 @@ +use super::ast::{Expr, Idx, Stmt}; +use super::lexer::{Tok, Token, lex}; +use super::{Func, MAX_CHAIN, MAX_PARSE_DEPTH, Rc}; + +struct Parser { + toks: Vec, + at: usize, + depth: usize, + /// Operators and accessors chained so far in the expression being parsed. + chain: usize, +} + +fn kw(t: &Tok, word: &str) -> bool { + matches!(t, Tok::Ident(s) if s.eq_ignore_ascii_case(word)) +} + +impl Parser { + fn peek(&self) -> &Tok { + self.toks.get(self.at).map_or(&Tok::Eof, |t| &t.tok) + } + + fn line(&self) -> usize { + self.toks.get(self.at).map_or(0, |t| t.line) + } + + fn next(&mut self) -> Tok { + let t = self.peek().clone(); + self.at += 1; + t + } + + fn is_op(&self, op: &str) -> bool { + matches!(self.peek(), Tok::Op(o) if *o == op) + } + + fn eat_op(&mut self, op: &str) -> bool { + if self.is_op(op) { + self.at += 1; + true + } else { + false + } + } + + fn expect_op(&mut self, op: &str) -> Result<(), String> { + if self.eat_op(op) { Ok(()) } else { Err(format!("line {}: expected {op}", self.line())) } + } + + fn eat_kw(&mut self, word: &str) -> bool { + if kw(self.peek(), word) { + self.at += 1; + true + } else { + false + } + } + + fn expect_kw(&mut self, word: &str) -> Result<(), String> { + if self.eat_kw(word) { Ok(()) } else { Err(format!("line {}: expected {word}", self.line())) } + } + + fn skip_newlines(&mut self) { + while matches!(self.peek(), Tok::Newline) { + self.at += 1; + } + } + + fn enter(&mut self) -> Result<(), String> { + self.depth += 1; + if self.depth > MAX_PARSE_DEPTH { + return Err("the script is nested too deeply".into()); + } + Ok(()) + } + + fn leave(&mut self) { + self.depth = self.depth.saturating_sub(1); + } + + /// One more link in an operator or accessor chain. + fn link(&mut self) -> Result<(), String> { + self.chain += 1; + if self.chain > MAX_CHAIN { + return Err(format!("line {}: an expression is too long", self.line())); + } + Ok(()) + } + + /// Statements up to one of `enders` (not consumed). + fn block(&mut self, enders: &[&str]) -> Result, String> { + self.enter()?; + let mut out = Vec::new(); + loop { + self.skip_newlines(); + match self.peek() { + Tok::Eof => break, + Tok::Ident(w) if enders.iter().any(|e| w.eq_ignore_ascii_case(e)) => break, + _ => out.push(self.statement()?), + } + } + self.leave(); + Ok(out) + } + + fn statement(&mut self) -> Result { + let line = self.line(); + match self.peek().clone() { + Tok::Ident(w) if w.eq_ignore_ascii_case("var") => { + self.at += 1; + let Tok::Ident(name) = self.next() else { return Err(format!("line {line}: expected a name after var")) }; + let init = if self.eat_op("=") { Some(self.top_expr()?) } else { None }; + Ok(Stmt::Var(name, init)) + } + Tok::Ident(w) if w.eq_ignore_ascii_case("if") => { + self.at += 1; + let mut arms = Vec::new(); + let cond = self.top_expr()?; + self.expect_kw("then")?; + let body = self.block(&["elseif", "else", "endif"])?; + arms.push((cond, body)); + let mut otherwise = None; + loop { + if self.eat_kw("elseif") { + let c = self.top_expr()?; + self.expect_kw("then")?; + let b = self.block(&["elseif", "else", "endif"])?; + arms.push((c, b)); + } else if self.eat_kw("else") { + otherwise = Some(self.block(&["endif"])?); + } else { + self.expect_kw("endif")?; + break; + } + } + Ok(Stmt::If(arms, otherwise)) + } + Tok::Ident(w) if w.eq_ignore_ascii_case("while") => { + self.at += 1; + let cond = self.top_expr()?; + self.expect_kw("do")?; + let body = self.block(&["endwhile"])?; + self.expect_kw("endwhile")?; + Ok(Stmt::While(cond, body)) + } + Tok::Ident(w) if w.eq_ignore_ascii_case("for") => { + self.at += 1; + let Tok::Ident(var) = self.next() else { return Err(format!("line {line}: expected a variable after for")) }; + self.expect_op("=")?; + let from = self.top_expr()?; + let up = if self.eat_kw("upto") { + true + } else if self.eat_kw("downto") { + false + } else { + return Err(format!("line {line}: expected upto or downto")); + }; + let to = self.top_expr()?; + let step = if self.eat_kw("step") { Some(self.top_expr()?) } else { None }; + self.expect_kw("do")?; + let body = self.block(&["endfor"])?; + self.expect_kw("endfor")?; + Ok(Stmt::For { var, from, to, up, step, body }) + } + Tok::Ident(w) if w.eq_ignore_ascii_case("foreach") => { + self.at += 1; + let Tok::Ident(var) = self.next() else { return Err(format!("line {line}: expected a variable after foreach")) }; + self.expect_kw("in")?; + self.expect_op("(")?; + let mut list = Vec::new(); + if !self.is_op(")") { + loop { + list.push(self.top_expr()?); + if !self.eat_op(",") { + break; + } + } + } + self.expect_op(")")?; + self.expect_kw("do")?; + let body = self.block(&["endfor"])?; + self.expect_kw("endfor")?; + Ok(Stmt::Foreach { var, list, body }) + } + Tok::Ident(w) if w.eq_ignore_ascii_case("func") => { + self.at += 1; + let Tok::Ident(name) = self.next() else { return Err(format!("line {line}: expected a name after func")) }; + self.expect_op("(")?; + let mut params = Vec::new(); + if !self.is_op(")") { + loop { + let Tok::Ident(p) = self.next() else { return Err(format!("line {line}: expected a parameter name")) }; + params.push(p); + if !self.eat_op(",") { + break; + } + } + } + self.expect_op(")")?; + self.expect_kw("do")?; + let body = self.block(&["endfunc"])?; + self.expect_kw("endfunc")?; + Ok(Stmt::Func { name, f: Rc::new(Func { params, body }) }) + } + Tok::Ident(w) if w.eq_ignore_ascii_case("return") => { + self.at += 1; + let v = if matches!(self.peek(), Tok::Newline | Tok::Eof) { None } else { Some(self.top_expr()?) }; + Ok(Stmt::Return(v)) + } + Tok::Ident(w) if w.eq_ignore_ascii_case("break") => { + self.at += 1; + Ok(Stmt::Break) + } + Tok::Ident(w) if w.eq_ignore_ascii_case("continue") => { + self.at += 1; + Ok(Stmt::Continue) + } + Tok::Ident(w) if w.eq_ignore_ascii_case("exit") => { + self.at += 1; + Ok(Stmt::Exit) + } + Tok::Ident(w) if w.eq_ignore_ascii_case("throw") => { + self.at += 1; + Ok(Stmt::Throw(self.top_expr()?)) + } + Tok::Ident(w) if w.eq_ignore_ascii_case("do") => { + // `do … end`: a block as one statement. + self.at += 1; + let body = self.block(&["end"])?; + self.expect_kw("end")?; + Ok(Stmt::If(vec![(Expr::Num(1.0), body)], None)) + } + _ => { + let e = self.top_expr()?; + if self.eat_op("=") { + let v = self.top_expr()?; + return Ok(Stmt::Assign(e, v)); + } + Ok(Stmt::Expr(e)) + } + } + } + + /// An expression that starts a tree of its own (a condition, a bound, a statement's + /// expression): its operator chain is counted from zero, whatever came before it. + fn top_expr(&mut self) -> Result { + self.chain = 0; + self.expr() + } + + fn expr(&mut self) -> Result { + self.enter()?; + let e = self.or_expr(); + self.leave(); + e + } + + fn or_expr(&mut self) -> Result { + let mut l = self.and_expr()?; + loop { + if self.eat_op("|") || self.eat_kw("or") { + self.link()?; + let r = self.and_expr()?; + l = Expr::Bin("|", Box::new(l), Box::new(r)); + } else { + return Ok(l); + } + } + } + + fn and_expr(&mut self) -> Result { + let mut l = self.cmp_expr()?; + loop { + if self.eat_op("&") || self.eat_kw("and") { + self.link()?; + let r = self.cmp_expr()?; + l = Expr::Bin("&", Box::new(l), Box::new(r)); + } else { + return Ok(l); + } + } + } + + fn cmp_expr(&mut self) -> Result { + let mut l = self.add_expr()?; + loop { + let op = match self.peek() { + Tok::Op("==") => "==", + Tok::Op("<>") => "<>", + Tok::Op("<=") => "<=", + Tok::Op(">=") => ">=", + Tok::Op("<") => "<", + Tok::Op(">") => ">", + Tok::Ident(w) if w.eq_ignore_ascii_case("eq") => "==", + Tok::Ident(w) if w.eq_ignore_ascii_case("ne") => "<>", + Tok::Ident(w) if w.eq_ignore_ascii_case("le") => "<=", + Tok::Ident(w) if w.eq_ignore_ascii_case("ge") => ">=", + Tok::Ident(w) if w.eq_ignore_ascii_case("lt") => "<", + Tok::Ident(w) if w.eq_ignore_ascii_case("gt") => ">", + _ => return Ok(l), + }; + self.at += 1; + self.link()?; + let r = self.add_expr()?; + l = Expr::Bin(op, Box::new(l), Box::new(r)); + } + } + + fn add_expr(&mut self) -> Result { + let mut l = self.mul_expr()?; + loop { + let op = match self.peek() { + Tok::Op("+") => "+", + Tok::Op("-") => "-", + _ => return Ok(l), + }; + self.at += 1; + self.link()?; + let r = self.mul_expr()?; + l = Expr::Bin(op, Box::new(l), Box::new(r)); + } + } + + fn mul_expr(&mut self) -> Result { + let mut l = self.unary()?; + loop { + let op = match self.peek() { + Tok::Op("*") => "*", + Tok::Op("/") => "/", + _ => return Ok(l), + }; + self.at += 1; + self.link()?; + let r = self.unary()?; + l = Expr::Bin(op, Box::new(l), Box::new(r)); + } + } + + fn unary(&mut self) -> Result { + self.enter()?; + let e = if self.eat_op("-") { + Ok(Expr::Neg(Box::new(self.unary()?))) + } else if self.eat_op("+") { + self.unary() + } else if self.eat_kw("not") || matches!(self.peek(), Tok::Ident(w) if w == "!") { + if matches!(self.peek(), Tok::Ident(w) if w == "!") { + self.at += 1; + } + Ok(Expr::Not(Box::new(self.unary()?))) + } else { + self.postfix() + }; + self.leave(); + e + } + + fn postfix(&mut self) -> Result { + let mut e = self.primary()?; + loop { + if matches!(self.peek(), Tok::Op("." | ".." | "[" | "(")) { + self.link()?; + } + if self.eat_op(".") { + let Tok::Ident(name) = self.next() else { return Err(format!("line {}: expected a name after .", self.line())) }; + e = Expr::Member(Box::new(e), name); + } else if self.eat_op("..") { + let Tok::Ident(name) = self.next() else { return Err(format!("line {}: expected a name after ..", self.line())) }; + e = Expr::Descend(Box::new(e), name); + } else if self.eat_op("[") { + let sign = match self.peek() { + Tok::Op("-") => Some(-1), + Tok::Op("+") => Some(1), + _ => None, + }; + let relative = sign.and_then(|sign| match (self.toks.get(self.at + 1).map(|t| &t.tok), self.toks.get(self.at + 2).map(|t| &t.tok)) { + (Some(Tok::Num(n)), Some(Tok::Op("]"))) if n.is_finite() && *n >= 0.0 && *n < 1e6 => Some(sign * (*n as i64)), + _ => None, + }); + let idx = if let Some(k) = relative { + self.at += 2; + Idx::Rel(k) + } else if self.eat_op("*") { + Idx::All + } else { + Idx::Abs(Box::new(self.expr()?)) + }; + self.expect_op("]")?; + e = Expr::Index(Box::new(e), idx); + } else if self.eat_op("(") { + let mut args = Vec::new(); + self.skip_newlines(); + if !self.is_op(")") { + loop { + args.push(self.expr()?); + self.skip_newlines(); + if !self.eat_op(",") { + break; + } + self.skip_newlines(); + } + } + self.expect_op(")")?; + e = Expr::Call(Box::new(e), args); + } else { + return Ok(e); + } + } + } + + fn primary(&mut self) -> Result { + let line = self.line(); + match self.next() { + Tok::Num(n) => Ok(Expr::Num(n)), + Tok::Str(s) => Ok(Expr::Str(s)), + Tok::Op("(") => { + self.skip_newlines(); + let e = self.expr()?; + self.skip_newlines(); + self.expect_op(")")?; + Ok(e) + } + Tok::Ident(w) if w.eq_ignore_ascii_case("null") => Ok(Expr::Null), + Tok::Ident(w) if w.eq_ignore_ascii_case("nan") => Ok(Expr::Num(f64::NAN)), + Tok::Ident(w) if w.eq_ignore_ascii_case("infinity") => Ok(Expr::Num(f64::INFINITY)), + Tok::Ident(w) if w.eq_ignore_ascii_case("true") => Ok(Expr::Num(1.0)), + Tok::Ident(w) if w.eq_ignore_ascii_case("false") => Ok(Expr::Num(0.0)), + Tok::Ident(w) => Ok(Expr::Ident(w)), + t => Err(format!("line {line}: unexpected {}", describe(&t))), + } + } +} + +fn describe(t: &Tok) -> String { + match t { + Tok::Num(n) => format!("number {n}"), + Tok::Str(s) => format!("string {s:?}"), + Tok::Ident(s) => s.clone(), + Tok::Op(o) => format!("{o:?}"), + Tok::Newline => "end of line".into(), + Tok::Eof => "end of script".into(), + } +} + +pub(super) fn parse(src: &str) -> Result, String> { + let toks = lex(src)?; + let mut p = Parser { toks, at: 0, depth: 0, chain: 0 }; + let body = p.block(&[])?; + match p.peek() { + Tok::Eof => Ok(body), + t => Err(format!("line {}: unexpected {}", p.line(), describe(t))), + } +} diff --git a/crates/js/src/formcalc/value.rs b/crates/js/src/formcalc/value.rs new file mode 100644 index 0000000000..5ea9d4c089 --- /dev/null +++ b/crates/js/src/formcalc/value.rs @@ -0,0 +1,100 @@ +// ── values ────────────────────────────────────────────────────────────────────────────────── + +#[derive(Clone, Debug, PartialEq)] +pub(super) enum Val { + Null, + Num(f64), + Str(String), + /// A form object, by index in the host. + Node(usize), + /// Several objects or values (`Row[*]`, `Row[*].Amount`). + List(Vec), + Host, + Layout, + Event, + /// `xfa` itself (`xfa.form`, `xfa.host`, …). + Xfa, + /// The instance manager of `name` under `parent`. + Im(usize, String), + /// A property object whose contents are not modelled (`$.border.fill…`): reads give + /// another sink, writes change nothing. + Sink, +} + +/// A number as FormCalc prints it: integers without a fraction, otherwise to at most 12 +/// decimals and 15 significant digits, without trailing zeros. 15 digits is the most a double +/// holds for every value, so a value typed with up to 15 digits prints back as typed +/// (`279893.88 * 1` is `279893.88`, not `279893.880000000005`), and the binary noise past +/// them never shows. The 12 decimals hide noise left by sums that should be zero. +pub(super) fn num_text(n: f64) -> String { + if !n.is_finite() { + return if n.is_nan() { + "NaN".into() + } else if n > 0.0 { + "Infinity".into() + } else { + "-Infinity".into() + }; + } + if n.fract() == 0.0 && n.abs() < 1e15 { + return format!("{}", n as i64); + } + // Digits before the point (at most 309 for a finite double); 1 for |n| < 1. + let int_digits = format!("{:.0}", n.abs().trunc()).len(); + let decimals = 15usize.saturating_sub(int_digits).min(12); + let s = format!("{n:.decimals$}"); + // Only a fraction loses its trailing zeros: with no decimals left they belong to the integer. + let s = if s.contains('.') { s.trim_end_matches('0').trim_end_matches('.') } else { s.as_str() }; + if s.is_empty() || s == "-0" { "0".into() } else { s.to_string() } +} + +/// `v` with `decimals` places, rounding exact halves away from zero (Rust's formatter rounds them to even). +pub(super) fn fixed(v: f64, decimals: usize) -> String { + let k = 10f64.powi(decimals.min(20) as i32); + let scaled = (v * k).round() / k; + // A small negative value rounds to -0.0, which would print as "-0". + let scaled = if scaled == 0.0 { 0.0 } else { scaled }; + format!("{:.decimals$}", if scaled.is_finite() { scaled } else { v }) +} + +/// The number a string holds: its leading numeric part (`12abc` → 12), `None` for none. +pub(super) fn parse_num(s: &str) -> Option { + let t = s.trim(); + if t.is_empty() { + return None; + } + let mut end = 0; + let b = t.as_bytes(); + let mut seen_digit = false; + let mut seen_dot = false; + let mut seen_e = false; + let mut i = 0; + if i < b.len() && (b[i] == b'-' || b[i] == b'+') { + i += 1; + } + while i < b.len() { + let c = b[i]; + if c.is_ascii_digit() { + seen_digit = true; + end = i + 1; + } else if c == b'.' && !seen_dot && !seen_e { + seen_dot = true; + } else if (c == b'e' || c == b'E') && seen_digit && !seen_e { + seen_e = true; + if i + 1 < b.len() && (b[i + 1] == b'-' || b[i + 1] == b'+') { + i += 1; + } + } else { + break; + } + i += 1; + } + if !seen_digit { + return None; + } + t.get(..end).and_then(|p| p.parse::().ok()).filter(|v| v.is_finite()) +} + +pub(super) fn is_numeric_text(s: &str) -> bool { + s.trim().parse::().is_ok_and(f64::is_finite) +} diff --git a/crates/js/src/formcalc/words.rs b/crates/js/src/formcalc/words.rs new file mode 100644 index 0000000000..d0d119abb5 --- /dev/null +++ b/crates/js/src/formcalc/words.rs @@ -0,0 +1,75 @@ +const ONES: [&str; 20] = [ + "Zero", + "One", + "Two", + "Three", + "Four", + "Five", + "Six", + "Seven", + "Eight", + "Nine", + "Ten", + "Eleven", + "Twelve", + "Thirteen", + "Fourteen", + "Fifteen", + "Sixteen", + "Seventeen", + "Eighteen", + "Nineteen", +]; + +const TENS: [&str; 10] = ["", "", "Twenty", "Thirty", "Forty", "Fifty", "Sixty", "Seventy", "Eighty", "Ninety"]; + +fn words_below_1000(n: u64) -> String { + let mut parts = Vec::new(); + if n >= 100 { + parts.push(format!("{} Hundred", ONES[(n / 100) as usize])); + } + let r = n % 100; + if r >= 20 { + parts.push(if r.is_multiple_of(10) { + TENS[(r / 10) as usize].to_string() + } else { + format!("{}-{}", TENS[(r / 10) as usize], ONES[(r % 10) as usize]) + }); + } else if r > 0 || n == 0 { + parts.push(ONES[r as usize].to_string()); + } + parts.join(" ") +} + +/// `WordNum`: an integer in English words (up to the billions). +pub(super) fn word_num(n: f64, cents: bool) -> String { + if !n.is_finite() || !(0.0..1e12).contains(&n) { + return String::new(); + } + let cents_total = (n * 100.0).round() as u64; + let whole = if cents { cents_total / 100 } else { n.trunc() as u64 }; + if whole >= 1_000_000_000_000 { + // Rounding to cents carried past the supported range. + return String::new(); + } + let mut parts = Vec::new(); + let groups = [(1_000_000_000u64, "Billion"), (1_000_000, "Million"), (1_000, "Thousand")]; + let mut rest = whole; + for (unit, name) in groups { + if rest >= unit { + parts.push(format!("{} {name}", words_below_1000(rest / unit))); + rest %= unit; + } + } + if rest > 0 || parts.is_empty() { + parts.push(words_below_1000(rest)); + } + let mut out = parts.join(" "); + if cents { + let c = cents_total % 100; + let dollars = if whole == 1 { "Dollar" } else { "Dollars" }; + let cent = if c == 1 { "Cent" } else { "Cents" }; + out.push_str(&format!(" {dollars} And {c:02} {cent}")); + } + out +} diff --git a/crates/js/src/globals.rs b/crates/js/src/globals.rs new file mode 100644 index 0000000000..3b9ebe6028 --- /dev/null +++ b/crates/js/src/globals.rs @@ -0,0 +1,297 @@ +use super::document::{field_object, get_field, get_nth_field_name, noop, num_fields, page_num_get, page_num_set, print, reset_form, submit_form}; +use super::format::{printd, printf, printx, scand}; +use super::host::{arg, function, host, s, text, text_line}; +use super::model::{DISPLAY_HIDDEN, DISPLAY_NO_PRINT, DISPLAY_NO_VIEW, DISPLAY_VISIBLE, Event, Panel, Request}; +use super::{ + Attribute, Context, JsArray, JsObject, JsResult, JsString, JsValue, Message, MessageButtons, MessageIcon, NativeFunction, ObjectInitializer, + js_string, +}; + +// ── app, util, console ────────────────────────────────────────────────────────────────────── + +/// A message argument: a string, or an object with `cMsg`, cut to a message's length. +fn message(v: &JsValue, key: &str, ctx: &mut Context) -> JsResult { + match named_params(v, key, ctx)? { + Some(o) => { + let m = o.get(JsString::from(key), ctx)?; + text_line(&m, ctx) + } + None => text_line(v, ctx), + } +} + +/// `v` as a method's single object of named parameters: an object (not an array or function) +/// that has `key`, the parameter every such call names. Any other value, a `new String(…)` or a +/// caught `Error` included, is the first positional argument, read as text. +fn named_params(v: &JsValue, key: &str, ctx: &mut Context) -> JsResult> { + match v.as_object() { + Some(o) if !o.is_array() && !o.is_callable() && o.has_property(JsString::from(key), ctx)? => Ok(Some(o.clone())), + _ => Ok(None), + } +} + +/// A message's title: `v` as text cut to a message's length, `None` when it is missing or empty. +fn title(v: &JsValue, ctx: &mut Context) -> JsResult> { + Ok(Some(text_line(v, ctx)?).filter(|t| !t.is_empty())) +} + +/// An icon or buttons number: `v` as a number, `None` when it is missing or isn't one (a value +/// that can't be converted counts as missing, so a bad argument never stops the message). +fn code(v: &JsValue, ctx: &mut Context) -> Option { + if v.is_undefined() || v.is_null() { + return None; + } + v.to_number(ctx).ok() +} + +/// `app.alert`'s message: `(cMsg, nIcon, nType, cTitle)`, or one object with those keys (as +/// the JavaScript for Acrobat API Reference allows for every method with named parameters). +pub(crate) fn alert_message(args: &[JsValue], ctx: &mut Context) -> JsResult { + let first = arg(args, 0); + let (text, icon, buttons, t) = match named_params(&first, "cMsg", ctx)? { + Some(o) => { + let get = |k: &str, ctx: &mut Context| o.get(JsString::from(k), ctx); + (get("cMsg", ctx)?, get("nIcon", ctx)?, get("nType", ctx)?, get("cTitle", ctx)?) + } + None => (first, arg(args, 1), arg(args, 2), arg(args, 3)), + }; + Ok(Message { + text: text_line(&text, ctx)?, + title: title(&t, ctx)?, + icon: MessageIcon::from_code(code(&icon, ctx)), + buttons: MessageButtons::from_code(code(&buttons, ctx)), + }) +} + +/// `xfa.host.messageBox`'s message: `(message, title, icon, buttons)`, as the XFA +/// specification orders them. +pub(crate) fn message_box_message(args: &[JsValue], ctx: &mut Context) -> JsResult { + Ok(Message { + text: text_line(&arg(args, 0), ctx)?, + title: title(&arg(args, 1), ctx)?, + icon: MessageIcon::from_code(code(&arg(args, 2), ctx)), + buttons: MessageButtons::from_code(code(&arg(args, 3), ctx)), + }) +} + +/// `app.alert(cMsg, nIcon, nType, cTitle)`: records the message with its title, icon and buttons, +/// given in that order or as one object with those keys, and returns 1 (the OK button). +fn alert(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let m = alert_message(args, ctx)?; + host(ctx)?.borrow_mut().alert(m); + // Always the OK button for now: the viewer can't ask the user and run the script on with + // the answer yet (#4291). + Ok(JsValue::from(1)) +} + +/// `app.beep()`: queues a request to beep. +fn beep(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { + host(ctx)?.borrow_mut().request(Request::Beep); + Ok(JsValue::undefined()) +} + +/// `app.launchURL(cURL)`: queues a request to open the URL, given as a string or as an object +/// with `cURL`. +fn launch_url(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let u = message(&arg(args, 0), "cURL", ctx)?; + host(ctx)?.borrow_mut().request(Request::LaunchUrl(u)); + Ok(JsValue::undefined()) +} + +/// What `app.execMenuItem(name)` asks for, for the menu items forms use: the side panels, Print +/// and Save As. Other items do nothing. +pub(crate) fn menu_item(name: &str) -> Option { + Some(match name { + "ShowHideFileAttachment" => Request::ShowPanel(Panel::Attachments), + "ShowHideBookmarks" => Request::ShowPanel(Panel::Bookmarks), + "ShowHideThumbnails" => Request::ShowPanel(Panel::Pages), + "ShowHideSignatures" => Request::ShowPanel(Panel::Signatures), + "ShowHideOptCont" => Request::ShowPanel(Panel::Layers), + "Print" => Request::Print, + // Save opens Save As too, as it always has for XFA forms' buttons: a click on a form + // never overwrites the file unasked. + "SaveAs" | "Save" => Request::SaveAs, + _ => return None, + }) +} + +/// `app.execMenuItem(name)`: queues the request [`menu_item`] gives for the name (Save opens +/// Save As too); an unknown name does nothing. +fn exec_menu_item(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let item = text(&arg(args, 0), ctx)?; + if let Some(r) = menu_item(&item) { + host(ctx)?.borrow_mut().request(r); + } + Ok(JsValue::undefined()) +} + +/// `app.response(…)`: always `null`, the answer for a canceled dialog, since no one is there to +/// type one. +fn response(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { + // No one to answer: as if the dialog was cancelled. + Ok(JsValue::null()) +} + +fn println(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let m = text_line(&arg(args, 0), ctx)?; + host(ctx)?.borrow_mut().print(m); + Ok(JsValue::undefined()) +} + +fn console_clear(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { + host(ctx)?.borrow_mut().console.clear(); + Ok(JsValue::undefined()) +} + +fn timer(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { + // Timers never fire in the sandbox; scripts get an object they can pass to clear*. + Ok(ObjectInitializer::new(ctx).build().into()) +} + +/// The `color` object: each named color (`transparent`, `black`, `red`, `cyan`, `gray`, …) as +/// an array of its color space (`T`, `G`, `RGB` or `CMYK`) followed by its components. +fn color_object(ctx: &mut Context) -> JsObject { + let mut entries: Vec<(&str, Vec)> = vec![ + ("transparent", vec![s("T")]), + ("black", vec![s("G"), 0.into()]), + ("white", vec![s("G"), 1.into()]), + ("red", vec![s("RGB"), 1.into(), 0.into(), 0.into()]), + ("green", vec![s("RGB"), 0.into(), 1.into(), 0.into()]), + ("blue", vec![s("RGB"), 0.into(), 0.into(), 1.into()]), + ("cyan", vec![s("CMYK"), 1.into(), 0.into(), 0.into(), 0.into()]), + ("magenta", vec![s("CMYK"), 0.into(), 1.into(), 0.into(), 0.into()]), + ("yellow", vec![s("CMYK"), 0.into(), 0.into(), 1.into(), 0.into()]), + ("dkGray", vec![s("G"), JsValue::from(0.25)]), + ("gray", vec![s("G"), JsValue::from(0.5)]), + ("ltGray", vec![s("G"), JsValue::from(0.75)]), + ]; + let arrays: Vec<(&str, JsValue)> = entries.drain(..).map(|(k, v)| (k, JsArray::from_iter(v, ctx).into())).collect(); + let mut o = ObjectInitializer::new(ctx); + for (k, v) in arrays { + o.property(JsString::from(k), v, Attribute::all()); + } + o.build() +} + +/// The XFA version scripts see as `xfa_version`. +const XFA_VERSION: f64 = 3.5; + +/// Defines the form object model's globals: the document's functions (`getField`, `resetForm`, +/// `print`, `submitForm` and others) and properties (`numFields`, `pageNum`, `numPages`, +/// `documentFileName`, `info`, the XFA version), and the `app`, `util`, `console`, `display` and +/// `color` objects. +pub(crate) fn install(ctx: &mut Context) -> JsResult<()> { + let globals: [(&str, usize, NativeFunction); 9] = [ + ("getField", 1, NativeFunction::from_fn_ptr(get_field)), + ("getNthFieldName", 1, NativeFunction::from_fn_ptr(get_nth_field_name)), + ("resetForm", 1, NativeFunction::from_fn_ptr(reset_form)), + ("print", 1, NativeFunction::from_fn_ptr(print)), + ("submitForm", 1, NativeFunction::from_fn_ptr(submit_form)), + ("mailDoc", 1, NativeFunction::from_fn_ptr(noop)), + ("calculateNow", 0, NativeFunction::from_fn_ptr(noop)), + ("syncAnnotScan", 0, NativeFunction::from_fn_ptr(noop)), + ("dirty", 0, NativeFunction::from_fn_ptr(noop)), + ]; + for (name, len, f) in globals { + ctx.register_global_callable(JsString::from(name), len, f)?; + } + let global = ctx.global_object(); + let rw = Attribute::CONFIGURABLE; + let nf = function(ctx, NativeFunction::from_fn_ptr(num_fields)); + let pg = function(ctx, NativeFunction::from_fn_ptr(page_num_get)); + let ps = function(ctx, NativeFunction::from_fn_ptr(page_num_set)); + let (file, pages, info, xfa) = { + let h = host(ctx)?.borrow(); + (h.doc.file_name.clone(), h.doc.num_pages, h.doc.info.clone(), h.doc.xfa) + }; + let mut io = ObjectInitializer::new(ctx); + for (k, v) in &info { + io.property(JsString::from(k.as_str()), s(v), Attribute::all()); + } + let info = io.build(); + use boa_engine::property::PropertyDescriptor; + global.define_property_or_throw(js_string!("numFields"), PropertyDescriptor::builder().get(nf).configurable(true), ctx)?; + global.define_property_or_throw(js_string!("pageNum"), PropertyDescriptor::builder().get(pg).set(ps).configurable(true), ctx)?; + ctx.register_global_property(js_string!("numPages"), JsValue::from(pages as f64), rw)?; + ctx.register_global_property(js_string!("documentFileName"), s(&file), rw)?; + ctx.register_global_property(js_string!("info"), info, rw)?; + // XFA forms check both before they run: every document sees the XFA version, and only an + // XFA form sees `xfa_installed`. + ctx.register_global_property(js_string!("xfa_version"), JsValue::from(XFA_VERSION), rw)?; + if xfa { + ctx.register_global_property(js_string!("xfa_installed"), JsValue::from(true), rw)?; + } + + let app = ObjectInitializer::new(ctx) + .function(NativeFunction::from_fn_ptr(alert), js_string!("alert"), 1) + .function(NativeFunction::from_fn_ptr(beep), js_string!("beep"), 0) + .function(NativeFunction::from_fn_ptr(response), js_string!("response"), 1) + .function(NativeFunction::from_fn_ptr(launch_url), js_string!("launchURL"), 2) + .function(NativeFunction::from_fn_ptr(exec_menu_item), js_string!("execMenuItem"), 1) + .function(NativeFunction::from_fn_ptr(timer), js_string!("setTimeOut"), 2) + .function(NativeFunction::from_fn_ptr(timer), js_string!("setInterval"), 2) + .function(NativeFunction::from_fn_ptr(noop), js_string!("clearTimeOut"), 1) + .function(NativeFunction::from_fn_ptr(noop), js_string!("clearInterval"), 1) + .property(js_string!("viewerType"), s("Exchange-Pro"), Attribute::READONLY) + .property(js_string!("viewerVariation"), s("Full"), Attribute::READONLY) + .property(js_string!("viewerVersion"), JsValue::from(24.0), Attribute::READONLY) + .property(js_string!("formsVersion"), JsValue::from(24.0), Attribute::READONLY) + .property(js_string!("platform"), s(platform()), Attribute::READONLY) + .property(js_string!("language"), s("ENU"), Attribute::READONLY) + .build(); + ctx.register_global_property(js_string!("app"), app, rw)?; + let util = ObjectInitializer::new(ctx) + .function(NativeFunction::from_fn_ptr(printf), js_string!("printf"), 1) + .function(NativeFunction::from_fn_ptr(printd), js_string!("printd"), 2) + .function(NativeFunction::from_fn_ptr(printx), js_string!("printx"), 2) + .function(NativeFunction::from_fn_ptr(scand), js_string!("scand"), 2) + .build(); + ctx.register_global_property(js_string!("util"), util, rw)?; + let console = ObjectInitializer::new(ctx) + .function(NativeFunction::from_fn_ptr(println), js_string!("println"), 1) + .function(NativeFunction::from_fn_ptr(noop), js_string!("show"), 0) + .function(NativeFunction::from_fn_ptr(noop), js_string!("hide"), 0) + .function(NativeFunction::from_fn_ptr(console_clear), js_string!("clear"), 0) + .build(); + ctx.register_global_property(js_string!("console"), console, rw)?; + let display = ObjectInitializer::new(ctx) + .property(js_string!("visible"), DISPLAY_VISIBLE, Attribute::READONLY) + .property(js_string!("hidden"), DISPLAY_HIDDEN, Attribute::READONLY) + .property(js_string!("noPrint"), DISPLAY_NO_PRINT, Attribute::READONLY) + .property(js_string!("noView"), DISPLAY_NO_VIEW, Attribute::READONLY) + .build(); + ctx.register_global_property(js_string!("display"), display, rw)?; + let color = color_object(ctx); + ctx.register_global_property(js_string!("color"), color, rw)?; + Ok(()) +} + +pub(crate) fn platform() -> &'static str { + if cfg!(target_os = "macos") { + "MAC" + } else if cfg!(target_os = "windows") { + "WIN" + } else { + "UNIX" + } +} + +pub(crate) fn event_object(ctx: &mut Context, e: &Event) -> JsObject { + let target = e.target.as_ref().map_or(JsValue::null(), |t| field_object(ctx, t).into()); + ObjectInitializer::new(ctx) + .property(js_string!("name"), s(&e.name), Attribute::all()) + .property(js_string!("type"), s(&e.kind), Attribute::all()) + .property(js_string!("value"), s(&e.value), Attribute::all()) + .property(js_string!("change"), s(&e.change), Attribute::all()) + .property(js_string!("changeEx"), s(&e.change), Attribute::all()) + .property(js_string!("willCommit"), e.will_commit, Attribute::all()) + .property(js_string!("rc"), true, Attribute::all()) + .property(js_string!("target"), target, Attribute::all()) + .property(js_string!("targetName"), s(e.target.as_deref().unwrap_or("")), Attribute::all()) + .property(js_string!("selStart"), 0, Attribute::all()) + .property(js_string!("selEnd"), 0, Attribute::all()) + .property(js_string!("commitKey"), 0, Attribute::all()) + .property(js_string!("modifier"), false, Attribute::all()) + .property(js_string!("shift"), false, Attribute::all()) + .build() +} diff --git a/crates/js/src/host.rs b/crates/js/src/host.rs new file mode 100644 index 0000000000..8b3b1e6f1c --- /dev/null +++ b/crates/js/src/host.rs @@ -0,0 +1,202 @@ +use super::model::{DocInfo, FieldState, FieldType, Request}; +use super::run::MAX_ARRAY_LENGTH; +use super::{ + Arc, BTreeSet, Context, FunctionObjectBuilder, JsArray, JsError, JsFunction, JsNativeError, JsResult, JsString, JsValue, Message, NativeFunction, + RefCell, xfa, +}; + +pub(crate) struct Host { + pub(crate) fields: Arc>, + pub(crate) changed: BTreeSet, + pub(crate) alerts: Vec, + pub(crate) console: Vec, + pub(crate) requests: Vec, + pub(crate) doc: DocInfo, + /// Bytes of text kept in alerts, console lines and requests, against [`xfa::MAX_PAYLOAD`]. + pub(crate) kept: usize, + /// Alerts, console lines and requests the limits left out. + pub(crate) dropped: usize, +} + +impl Host { + /// Room for `bytes` more bytes of kept text: charged when there is, a drop counted when not. + fn room(&mut self, bytes: usize) -> bool { + match self.kept.checked_add(bytes).filter(|k| *k <= xfa::MAX_PAYLOAD) { + Some(k) => { + self.kept = k; + true + } + None => { + self.dropped = self.dropped.saturating_add(1); + false + } + } + } + + /// Keep message `m`, its text and title charged to the run's byte budget, up to + /// [`xfa::MAX_ALERTS`] messages. + pub(crate) fn alert(&mut self, m: Message) { + if self.alerts.len() >= xfa::MAX_ALERTS { + self.dropped = self.dropped.saturating_add(1); + } else if self.room(m.bytes()) { + self.alerts.push(m); + } + } + + pub(crate) fn print(&mut self, m: String) { + if self.console.len() >= xfa::MAX_CONSOLE { + self.dropped = self.dropped.saturating_add(1); + } else if self.room(m.len()) { + self.console.push(m); + } + } + + /// Record `r`, its names and URLs cut to a message's length, up to [`xfa::MAX_EFFECTS`] + /// requests and the run's byte budget. + pub(crate) fn request(&mut self, r: Request) { + if self.requests.len() >= xfa::MAX_EFFECTS { + self.dropped = self.dropped.saturating_add(1); + return; + } + let r = match r { + Request::Reset(names) => Request::Reset(names.into_iter().map(xfa::clip).collect()), + Request::LaunchUrl(u) => Request::LaunchUrl(xfa::clip(u)), + Request::Submit(u) => Request::Submit(xfa::clip(u)), + Request::Focus(n) => Request::Focus(xfa::clip(n)), + other => other, + }; + let bytes = match &r { + Request::Reset(names) => names.iter().fold(0usize, |n, s| n.saturating_add(s.len()).saturating_add(std::mem::size_of::())), + Request::LaunchUrl(u) | Request::Submit(u) | Request::Focus(u) => u.len(), + _ => 0, + }; + if self.room(bytes) { + self.requests.push(r); + } + } +} + +pub(crate) type Shared = RefCell; + +/// The host state, installed before scripts run. +pub(crate) fn host(ctx: &Context) -> JsResult<&Shared> { + ctx.get_data::().ok_or_else(|| error("the script host is not set up")) +} + +pub(crate) fn s(v: &str) -> JsValue { + JsValue::from(JsString::from(v)) +} + +pub(crate) fn text(v: &JsValue, ctx: &mut Context) -> JsResult { + if v.is_undefined() || v.is_null() { + return Ok(String::new()); + } + Ok(v.to_string(ctx)?.to_std_string_escaped()) +} + +/// [`text`] cut to a message's length ([`xfa::clip`]), without first copying all of a longer +/// string. +pub(crate) fn text_line(v: &JsValue, ctx: &mut Context) -> JsResult { + if v.is_undefined() || v.is_null() { + return Ok(String::new()); + } + let js = v.to_string(ctx)?; + // Two UTF-16 units per character at most: the clip still sees more than a line's worth. + let keep = xfa::MAX_LINE.saturating_add(1).saturating_mul(2); + let js = if js.len() > keep { js.get(..keep).unwrap_or_default() } else { js }; + Ok(xfa::clip(js.to_std_string_escaped())) +} + +pub(crate) fn arg(args: &[JsValue], i: usize) -> JsValue { + args.get(i).cloned().unwrap_or_default() +} + +pub(crate) fn error(msg: impl Into) -> JsError { + JsNativeError::typ().with_message(msg.into()).into() +} + +/// A JavaScript number for a text value that reads as one, as Acrobat's `field.value` gives. +pub(crate) fn as_number(t: &str) -> Option { + let t = t.trim(); + if t.is_empty() || t.starts_with('+') || t.len() > 1 && t.starts_with('0') && !t.starts_with("0.") { + return None; + } + t.parse::().ok().filter(|v| v.is_finite()) +} + +pub(crate) fn function(ctx: &mut Context, f: NativeFunction) -> JsFunction { + FunctionObjectBuilder::new(ctx.realm(), f).build() +} + +pub(crate) fn strings(v: &JsValue, ctx: &mut Context) -> JsResult> { + if let Some(o) = v.as_object() + && o.is_array() + { + let a = JsArray::from_object(o.clone())?; + let n = a.length(ctx)?; + if n > MAX_ARRAY_LENGTH { + return Err(error(format!("array length exceeds {MAX_ARRAY_LENGTH}"))); + } + let mut out = Vec::new(); + for i in 0..n { + let x = a.get(i, ctx)?; + out.push(text(&x, ctx)?); + } + return Ok(out); + } + Ok(if v.is_undefined() || v.is_null() { Vec::new() } else { vec![text(v, ctx)?] }) +} + +pub(crate) fn colour(c: &Option>, ctx: &mut Context) -> JsValue { + match c { + None => JsArray::from_iter([s("T")], ctx).into(), + Some(parts) => { + let vals: Vec = + parts.iter().enumerate().map(|(i, p)| if i == 0 { s(p) } else { JsValue::from(p.parse::().unwrap_or(0.0)) }).collect(); + JsArray::from_iter(vals, ctx).into() + } + } +} + +// ── field objects ─────────────────────────────────────────────────────────────────────────── + +fn field_index(ctx: &Context, name: &JsString) -> JsResult { + let n = name.to_std_string_escaped(); + host(ctx)?.borrow().fields.iter().position(|f| f.name == n).ok_or_else(|| error(format!("no field named {n}"))) +} + +pub(crate) fn read(ctx: &Context, name: &JsString) -> JsResult { + let i = field_index(ctx, name)?; + Ok(host(ctx)?.borrow().fields[i].clone()) +} + +pub(crate) fn write(ctx: &Context, name: &JsString, change: impl FnOnce(&mut FieldState)) -> JsResult<()> { + let i = field_index(ctx, name)?; + let mut h = host(ctx)?.borrow_mut(); + let field = Arc::make_mut(&mut h.fields).get_mut(i).ok_or_else(|| error("the field is no longer available"))?; + change(field); + h.changed.insert(i); + Ok(()) +} + +pub(crate) fn value_of(f: &FieldState, ctx: &mut Context) -> JsValue { + match f.kind { + FieldType::CheckBox | FieldType::RadioButton => s(f.value.first().map_or("Off", String::as_str)), + FieldType::ListBox if f.value.len() > 1 => { + let vals: Vec = f.value.iter().map(|v| s(v)).collect(); + JsArray::from_iter(vals, ctx).into() + } + _ => { + let v = f.single(); + as_number(&v).map_or_else(|| s(&v), JsValue::from) + } + } +} + +pub(crate) fn set_value(f: &mut FieldState, vals: Vec) { + f.value = match f.kind { + FieldType::CheckBox | FieldType::RadioButton => vals.into_iter().filter(|v| v != "Off" && !v.is_empty()).take(1).collect(), + FieldType::ListBox => vals, + _ => vals.into_iter().take(1).collect(), + }; +} diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index 4db899c1c1..93f57e7528 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -34,1712 +34,28 @@ use boa_engine::property::Attribute; use boa_engine::realm::Realm; use boa_engine::{Context, JsError, JsNativeError, JsObject, JsResult, JsString, JsValue, NativeFunction, Source, js_string}; -/// The kinds of field, as `field.type` names them. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum FieldType { - Text, - CheckBox, - RadioButton, - ComboBox, - ListBox, - Button, - Signature, -} +mod document; +mod format; +mod globals; +mod host; +mod model; +mod run; -impl FieldType { - pub fn name(self) -> &'static str { - match self { - FieldType::Text => "text", - FieldType::CheckBox => "checkbox", - FieldType::RadioButton => "radiobutton", - FieldType::ComboBox => "combobox", - FieldType::ListBox => "listbox", - FieldType::Button => "button", - FieldType::Signature => "signature", - } - } -} - -/// `display.*`: visible, hidden, noPrint, noView. -pub const DISPLAY_VISIBLE: i32 = 0; -pub const DISPLAY_HIDDEN: i32 = 1; -pub const DISPLAY_NO_PRINT: i32 = 2; -pub const DISPLAY_NO_VIEW: i32 = 3; - -/// A field as scripts see it. -#[derive(Clone, Debug, PartialEq)] -pub struct FieldState { - pub name: String, - pub kind: FieldType, - /// Text: the text. Check box / radio: the selected export value (empty when off). Choice: - /// the selected export values. - pub value: Vec, - pub default: Vec, - pub readonly: bool, - pub required: bool, - pub display: i32, - /// Choice options (export value, display text); check boxes and radios: their export - /// values, one per widget. - pub options: Vec<(String, String)>, - pub char_limit: Option, - /// `textColor` / `fillColor` as Acrobat colour arrays (`["RGB", r, g, b]` etc.), when set. - pub text_color: Option>, - pub fill_color: Option>, -} - -impl FieldState { - pub fn new(name: impl Into, kind: FieldType, value: Vec) -> FieldState { - FieldState { - name: name.into(), - kind, - value, - default: Vec::new(), - readonly: false, - required: false, - display: DISPLAY_VISIBLE, - options: Vec::new(), - char_limit: None, - text_color: None, - fill_color: None, - } - } - - fn single(&self) -> String { - self.value.first().cloned().unwrap_or_default() - } -} - -/// The event a script runs for. -#[derive(Clone, Debug, PartialEq)] -pub struct Event { - /// `Keystroke`, `Validate`, `Calculate`, `Format`, `Mouse Up`, `Open`, … - pub name: String, - /// `Field`, `Doc`, `Page`, … - pub kind: String, - /// The field the event is for. - pub target: Option, - pub value: String, - pub change: String, - pub will_commit: bool, -} - -impl Event { - /// A field event (`event.type` = "Field"). - pub fn field(name: &str, target: &str, value: &str) -> Event { - Event { name: name.into(), kind: "Field".into(), target: Some(target.into()), value: value.into(), change: String::new(), will_commit: true } - } - - /// A document event (`event.type` = "Doc"), e.g. Open or WillSave. - pub fn doc(name: &str) -> Event { - Event { name: name.into(), kind: "Doc".into(), target: None, value: String::new(), change: String::new(), will_commit: false } - } -} - -/// About the document, for `this.numPages`, `this.info` and the like. -#[derive(Clone, Debug, Default, PartialEq)] -pub struct DocInfo { - pub file_name: String, - pub num_pages: usize, - /// The current page, 0-based. - pub page: usize, - /// Document information (`this.info.title` etc.): lower-case key, value. - pub info: Vec<(String, String)>, - /// The document has an XFA form, so scripts see `xfa_installed`. - pub xfa: bool, -} - -/// Something a script asked the viewer to do. -#[derive(Clone, Debug, PartialEq)] -pub enum Request { - /// `this.resetForm([names])` (empty: every field). - Reset(Vec), - Print, - /// `this.pageNum = n` (0-based). - GoToPage(usize), - LaunchUrl(String), - /// `this.submitForm(url)`: never sent on PdfCraft's own. - Submit(String), - /// `field.setFocus()`. - Focus(String), - Beep, - /// `app.execMenuItem("SaveAs")` (XFA forms' Save buttons). - SaveAs, - /// `app.execMenuItem("ShowHide…")`: show the side panel, or hide it when it is showing. - ShowPanel(Panel), -} - -/// A side panel a script can show or hide (`app.execMenuItem("ShowHideFileAttachment")`, …). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Panel { - /// The document's bookmarks (outline). - Bookmarks, - /// Page thumbnails. - Pages, - /// The files attached to the document. - Attachments, - /// The document's signatures and their validity. - Signatures, - /// The document's layers (optional content). - Layers, -} - -impl Panel { - /// The panel's name in automation output, fixed whatever the variant is called. - pub fn id(self) -> &'static str { - match self { - Panel::Bookmarks => "bookmarks", - Panel::Pages => "pages", - Panel::Attachments => "attachments", - Panel::Signatures => "signatures", - Panel::Layers => "layers", - } - } -} - -/// What running a script did. -#[derive(Clone, Debug, Default, PartialEq)] -pub struct Outcome { - /// `event.rc`: `false` rejects the keystroke or value. - pub rc: bool, - /// `event.value` after the script. - pub value: String, - pub change: String, - /// Fields whose value or properties the script changed, as they are now. - pub changed: Vec, - pub alerts: Vec, - pub console: Vec, - pub requests: Vec, - /// The script's error (syntax error, exception, or a limit reached), if it failed. - pub error: Option, - /// Errors in the document-level scripts that ran first. Each is a script of its own: one - /// that throws stops where it failed, and the others and the script itself still run (#3940). - pub doc_errors: Vec, - /// The script's completion value (what the console prints), unless undefined. - pub result: Option, -} - -/// Bounds that keep a script from hanging the viewer. -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct Limits { - pub loop_iterations: u64, - pub recursion: usize, -} - -impl Default for Limits { - fn default() -> Self { - Limits { loop_iterations: 1_000_000, recursion: 256 } - } -} - -struct Host { - fields: Arc>, - changed: BTreeSet, - alerts: Vec, - console: Vec, - requests: Vec, - doc: DocInfo, - /// Bytes of text kept in alerts, console lines and requests, against [`xfa::MAX_PAYLOAD`]. - kept: usize, - /// Alerts, console lines and requests the limits left out. - dropped: usize, -} - -impl Host { - /// Room for `bytes` more bytes of kept text: charged when there is, a drop counted when not. - fn room(&mut self, bytes: usize) -> bool { - match self.kept.checked_add(bytes).filter(|k| *k <= xfa::MAX_PAYLOAD) { - Some(k) => { - self.kept = k; - true - } - None => { - self.dropped = self.dropped.saturating_add(1); - false - } - } - } - - fn alert(&mut self, m: String) { - if self.alerts.len() >= xfa::MAX_ALERTS { - self.dropped = self.dropped.saturating_add(1); - } else if self.room(m.len()) { - self.alerts.push(m); - } - } - - fn print(&mut self, m: String) { - if self.console.len() >= xfa::MAX_CONSOLE { - self.dropped = self.dropped.saturating_add(1); - } else if self.room(m.len()) { - self.console.push(m); - } - } - - /// Record `r`, its names and URLs cut to a message's length, up to [`xfa::MAX_EFFECTS`] - /// requests and the run's byte budget. - fn request(&mut self, r: Request) { - if self.requests.len() >= xfa::MAX_EFFECTS { - self.dropped = self.dropped.saturating_add(1); - return; - } - let r = match r { - Request::Reset(names) => Request::Reset(names.into_iter().map(xfa::clip).collect()), - Request::LaunchUrl(u) => Request::LaunchUrl(xfa::clip(u)), - Request::Submit(u) => Request::Submit(xfa::clip(u)), - Request::Focus(n) => Request::Focus(xfa::clip(n)), - other => other, - }; - let bytes = match &r { - Request::Reset(names) => names.iter().fold(0usize, |n, s| n.saturating_add(s.len()).saturating_add(std::mem::size_of::())), - Request::LaunchUrl(u) | Request::Submit(u) | Request::Focus(u) => u.len(), - _ => 0, - }; - if self.room(bytes) { - self.requests.push(r); - } - } -} - -type Shared = RefCell; - -/// The host state, installed before scripts run. -fn host(ctx: &Context) -> JsResult<&Shared> { - ctx.get_data::().ok_or_else(|| error("the script host is not set up")) -} - -fn s(v: &str) -> JsValue { - JsValue::from(JsString::from(v)) -} - -fn text(v: &JsValue, ctx: &mut Context) -> JsResult { - if v.is_undefined() || v.is_null() { - return Ok(String::new()); - } - Ok(v.to_string(ctx)?.to_std_string_escaped()) -} - -/// [`text`] cut to a message's length ([`xfa::clip`]), without first copying all of a longer -/// string. -fn text_line(v: &JsValue, ctx: &mut Context) -> JsResult { - if v.is_undefined() || v.is_null() { - return Ok(String::new()); - } - let js = v.to_string(ctx)?; - // Two UTF-16 units per character at most: the clip still sees more than a line's worth. - let keep = xfa::MAX_LINE.saturating_add(1).saturating_mul(2); - let js = if js.len() > keep { js.get(..keep).unwrap_or_default() } else { js }; - Ok(xfa::clip(js.to_std_string_escaped())) -} - -fn arg(args: &[JsValue], i: usize) -> JsValue { - args.get(i).cloned().unwrap_or_default() -} - -fn error(msg: impl Into) -> JsError { - JsNativeError::typ().with_message(msg.into()).into() -} - -/// A JavaScript number for a text value that reads as one, as Acrobat's `field.value` gives. -fn as_number(t: &str) -> Option { - let t = t.trim(); - if t.is_empty() || t.starts_with('+') || t.len() > 1 && t.starts_with('0') && !t.starts_with("0.") { - return None; - } - t.parse::().ok().filter(|v| v.is_finite()) -} - -fn function(ctx: &mut Context, f: NativeFunction) -> JsFunction { - FunctionObjectBuilder::new(ctx.realm(), f).build() -} - -fn strings(v: &JsValue, ctx: &mut Context) -> JsResult> { - if let Some(o) = v.as_object() - && o.is_array() - { - let a = JsArray::from_object(o.clone())?; - let n = a.length(ctx)?; - if n > MAX_ARRAY_LENGTH { - return Err(error(format!("array length exceeds {MAX_ARRAY_LENGTH}"))); - } - let mut out = Vec::new(); - for i in 0..n { - let x = a.get(i, ctx)?; - out.push(text(&x, ctx)?); - } - return Ok(out); - } - Ok(if v.is_undefined() || v.is_null() { Vec::new() } else { vec![text(v, ctx)?] }) -} - -fn colour(c: &Option>, ctx: &mut Context) -> JsValue { - match c { - None => JsArray::from_iter([s("T")], ctx).into(), - Some(parts) => { - let vals: Vec = - parts.iter().enumerate().map(|(i, p)| if i == 0 { s(p) } else { JsValue::from(p.parse::().unwrap_or(0.0)) }).collect(); - JsArray::from_iter(vals, ctx).into() - } - } -} - -// ── field objects ─────────────────────────────────────────────────────────────────────────── - -fn field_index(ctx: &Context, name: &JsString) -> JsResult { - let n = name.to_std_string_escaped(); - host(ctx)?.borrow().fields.iter().position(|f| f.name == n).ok_or_else(|| error(format!("no field named {n}"))) -} - -fn read(ctx: &Context, name: &JsString) -> JsResult { - let i = field_index(ctx, name)?; - Ok(host(ctx)?.borrow().fields[i].clone()) -} - -fn write(ctx: &Context, name: &JsString, change: impl FnOnce(&mut FieldState)) -> JsResult<()> { - let i = field_index(ctx, name)?; - let mut h = host(ctx)?.borrow_mut(); - let field = Arc::make_mut(&mut h.fields).get_mut(i).ok_or_else(|| error("the field is no longer available"))?; - change(field); - h.changed.insert(i); - Ok(()) -} - -fn value_of(f: &FieldState, ctx: &mut Context) -> JsValue { - match f.kind { - FieldType::CheckBox | FieldType::RadioButton => s(f.value.first().map_or("Off", String::as_str)), - FieldType::ListBox if f.value.len() > 1 => { - let vals: Vec = f.value.iter().map(|v| s(v)).collect(); - JsArray::from_iter(vals, ctx).into() - } - _ => { - let v = f.single(); - as_number(&v).map_or_else(|| s(&v), JsValue::from) - } - } -} - -fn set_value(f: &mut FieldState, vals: Vec) { - f.value = match f.kind { - FieldType::CheckBox | FieldType::RadioButton => vals.into_iter().filter(|v| v != "Off" && !v.is_empty()).take(1).collect(), - FieldType::ListBox => vals, - _ => vals.into_iter().take(1).collect(), - }; -} - -macro_rules! getter { - ($ctx:expr, $name:expr, |$f:ident, $c:ident| $body:expr) => {{ - fn get(_: &JsValue, _: &[JsValue], name: &JsString, $c: &mut Context) -> JsResult { - let $f = read($c, name)?; - Ok($body) - } - function($ctx, NativeFunction::from_copy_closure_with_captures(get, $name.clone())) - }}; -} - -fn field_object(ctx: &mut Context, name: &str) -> JsObject { - let n = JsString::from(name); - let value_get = getter!(ctx, n, |f, c| value_of(&f, c)); - let value_set = { - fn set(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { - let vals = strings(&arg(args, 0), c)?; - write(c, name, |f| set_value(f, vals))?; - Ok(JsValue::undefined()) - } - function(ctx, NativeFunction::from_copy_closure_with_captures(set, n.clone())) - }; - let as_string = getter!(ctx, n, |f, _c| s(&match f.kind { - FieldType::CheckBox | FieldType::RadioButton => f.value.first().cloned().unwrap_or_else(|| "Off".into()), - _ => f.value.join(","), - })); - let default = getter!(ctx, n, |f, _c| s(&f.default.first().cloned().unwrap_or_default())); - let kind = getter!(ctx, n, |f, _c| s(f.kind.name())); - let fname = getter!(ctx, n, |f, _c| s(&f.name)); - let readonly_get = getter!(ctx, n, |f, _c| JsValue::from(f.readonly)); - let readonly_set = { - fn set(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { - let on = arg(args, 0).to_boolean(); - write(c, name, |f| f.readonly = on)?; - Ok(JsValue::undefined()) - } - function(ctx, NativeFunction::from_copy_closure_with_captures(set, n.clone())) - }; - let required_get = getter!(ctx, n, |f, _c| JsValue::from(f.required)); - let required_set = { - fn set(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { - let on = arg(args, 0).to_boolean(); - write(c, name, |f| f.required = on)?; - Ok(JsValue::undefined()) - } - function(ctx, NativeFunction::from_copy_closure_with_captures(set, n.clone())) - }; - let display_get = getter!(ctx, n, |f, _c| JsValue::from(f.display)); - let display_set = { - fn set(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { - let d = arg(args, 0).to_number(c)? as i32; - write(c, name, |f| f.display = d.clamp(0, 3))?; - Ok(JsValue::undefined()) - } - function(ctx, NativeFunction::from_copy_closure_with_captures(set, n.clone())) - }; - let hidden_get = getter!(ctx, n, |f, _c| JsValue::from(f.display == DISPLAY_HIDDEN)); - let hidden_set = { - fn set(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { - let on = arg(args, 0).to_boolean(); - write(c, name, |f| f.display = if on { DISPLAY_HIDDEN } else { DISPLAY_VISIBLE })?; - Ok(JsValue::undefined()) - } - function(ctx, NativeFunction::from_copy_closure_with_captures(set, n.clone())) - }; - let num_items = getter!(ctx, n, |f, _c| JsValue::from(f.options.len() as f64)); - let char_limit = getter!(ctx, n, |f, _c| JsValue::from(f.char_limit.unwrap_or(0) as f64)); - let export_values = getter!(ctx, n, |f, c| { - let vals: Vec = f.options.iter().map(|o| s(&o.0)).collect(); - JsArray::from_iter(vals, c).into() - }); - let indices = getter!(ctx, n, |f, c| { - let idx: Vec = f.options.iter().enumerate().filter(|(_, o)| f.value.contains(&o.0)).map(|(i, _)| JsValue::from(i as f64)).collect(); - if idx.len() == 1 { idx[0].clone() } else { JsArray::from_iter(idx, c).into() } - }); - let text_color_get = getter!(ctx, n, |f, c| colour(&f.text_color, c)); - let text_color_set = { - fn set(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { - let v = strings(&arg(args, 0), c)?; - write(c, name, |f| f.text_color = Some(v))?; - Ok(JsValue::undefined()) - } - function(ctx, NativeFunction::from_copy_closure_with_captures(set, n.clone())) - }; - let fill_color_get = getter!(ctx, n, |f, c| colour(&f.fill_color, c)); - let fill_color_set = { - fn set(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { - let v = strings(&arg(args, 0), c)?; - write(c, name, |f| f.fill_color = Some(v))?; - Ok(JsValue::undefined()) - } - function(ctx, NativeFunction::from_copy_closure_with_captures(set, n.clone())) - }; - - fn get_item_at(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { - let f = read(c, name)?; - let i = arg(args, 0).to_number(c)?; - // bExportValue defaults to true. - let export = args.get(1).is_none_or(JsValue::to_boolean); - let i = if i < 0.0 { f.options.len().saturating_sub(1) } else { i as usize }; - let o = f.options.get(i).ok_or_else(|| error(format!("{} has no item {i}", f.name)))?; - Ok(s(if export { &o.0 } else { &o.1 })) - } - fn is_box_checked(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { - let f = read(c, name)?; - let i = arg(args, 0).to_number(c)? as usize; - let on = f.options.get(i).is_some_and(|o| f.value.first() == Some(&o.0)); - Ok(JsValue::from(on)) - } - fn check_this_box(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { - let f = read(c, name)?; - let i = arg(args, 0).to_number(c)? as usize; - let on = args.get(1).is_none_or(JsValue::to_boolean); - let export = f.options.get(i).map(|o| o.0.clone()).ok_or_else(|| error(format!("{} has no widget {i}", f.name)))?; - write(c, name, |f| { - if on { - f.value = vec![export]; - } else if f.value.first() == Some(&export) { - f.value.clear(); - } - })?; - Ok(JsValue::undefined()) - } - fn set_focus(_: &JsValue, _: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { - host(c)?.borrow_mut().request(Request::Focus(name.to_std_string_escaped())); - Ok(JsValue::undefined()) - } - - let rw = Attribute::CONFIGURABLE | Attribute::ENUMERABLE; - let get_item_at = NativeFunction::from_copy_closure_with_captures(get_item_at, n.clone()); - let is_box_checked = NativeFunction::from_copy_closure_with_captures(is_box_checked, n.clone()); - let check_this_box = NativeFunction::from_copy_closure_with_captures(check_this_box, n.clone()); - let set_focus = NativeFunction::from_copy_closure_with_captures(set_focus, n.clone()); - ObjectInitializer::new(ctx) - .accessor(js_string!("value"), Some(value_get), Some(value_set), rw) - .accessor(js_string!("valueAsString"), Some(as_string), None, rw) - .accessor(js_string!("defaultValue"), Some(default), None, rw) - .accessor(js_string!("type"), Some(kind), None, rw) - .accessor(js_string!("name"), Some(fname), None, rw) - .accessor(js_string!("readonly"), Some(readonly_get), Some(readonly_set), rw) - .accessor(js_string!("required"), Some(required_get), Some(required_set), rw) - .accessor(js_string!("display"), Some(display_get), Some(display_set), rw) - .accessor(js_string!("hidden"), Some(hidden_get), Some(hidden_set), rw) - .accessor(js_string!("numItems"), Some(num_items), None, rw) - .accessor(js_string!("charLimit"), Some(char_limit), None, rw) - .accessor(js_string!("exportValues"), Some(export_values), None, rw) - .accessor(js_string!("currentValueIndices"), Some(indices), None, rw) - .accessor(js_string!("textColor"), Some(text_color_get), Some(text_color_set), rw) - .accessor(js_string!("fillColor"), Some(fill_color_get), Some(fill_color_set), rw) - .function(get_item_at, js_string!("getItemAt"), 2) - .function(is_box_checked, js_string!("isBoxChecked"), 1) - .function(check_this_box, js_string!("checkThisBox"), 2) - .function(set_focus, js_string!("setFocus"), 0) - .build() -} - -// ── the document (global object) ──────────────────────────────────────────────────────────── - -fn get_field(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { - let name = text(&arg(args, 0), ctx)?; - let exists = host(ctx)?.borrow().fields.iter().any(|f| f.name == name); - if exists { - return Ok(field_object(ctx, &name).into()); - } - // A parent name (e.g. "total" for "total.0"): its first kid stands in, as for groups. - let prefix = format!("{name}."); - let kid = host(ctx)?.borrow().fields.iter().find(|f| f.name.starts_with(&prefix)).map(|f| f.name.clone()); - Ok(kid.map_or(JsValue::null(), |k| field_object(ctx, &k).into())) -} - -fn get_nth_field_name(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { - let i = arg(args, 0).to_number(ctx)?; - let name = host(ctx)?.borrow().fields.get(i.max(0.0) as usize).map(|f| f.name.clone()); - Ok(name.map_or(JsValue::null(), |n| s(&n))) -} - -fn reset_form(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { - let names = strings(&arg(args, 0), ctx)?; - host(ctx)?.borrow_mut().request(Request::Reset(names)); - Ok(JsValue::undefined()) -} - -fn print(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { - host(ctx)?.borrow_mut().request(Request::Print); - Ok(JsValue::undefined()) -} - -fn submit_form(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { - let a = arg(args, 0); - let url = match a.as_object() { - Some(o) if !o.is_array() => { - let u = o.get(js_string!("cURL"), ctx)?; - text(&u, ctx)? - } - _ => text(&a, ctx)?, - }; - host(ctx)?.borrow_mut().request(Request::Submit(url)); - Ok(JsValue::undefined()) -} - -fn noop(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { - Ok(JsValue::undefined()) -} - -fn num_fields(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { - Ok(JsValue::from(host(ctx)?.borrow().fields.len() as f64)) -} - -fn page_num_get(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { - Ok(JsValue::from(host(ctx)?.borrow().doc.page as f64)) -} - -fn page_num_set(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { - let n = arg(args, 0).to_number(ctx)?; - let mut h = host(ctx)?.borrow_mut(); - let last = h.doc.num_pages.saturating_sub(1); - let p = (n.max(0.0) as usize).min(last); - h.doc.page = p; - h.request(Request::GoToPage(p)); - Ok(JsValue::undefined()) -} - -// ── app, util, console ────────────────────────────────────────────────────────────────────── - -/// A message argument: a string, or an object with `cMsg`, cut to a message's length. -fn message(v: &JsValue, key: &str, ctx: &mut Context) -> JsResult { - match v.as_object() { - Some(o) if !o.is_array() && !o.is_callable() => { - let m = o.get(JsString::from(key), ctx)?; - text_line(&m, ctx) - } - _ => text_line(v, ctx), - } -} - -/// `app.alert(cMsg)`: records the message, given as a string or as an object with `cMsg`, and -/// returns 1 (the OK button). -fn alert(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { - let m = message(&arg(args, 0), "cMsg", ctx)?; - host(ctx)?.borrow_mut().alert(m); - // The OK button. - Ok(JsValue::from(1)) -} - -/// `app.beep()`: queues a request to beep. -fn beep(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { - host(ctx)?.borrow_mut().request(Request::Beep); - Ok(JsValue::undefined()) -} - -/// `app.launchURL(cURL)`: queues a request to open the URL, given as a string or as an object -/// with `cURL`. -fn launch_url(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { - let u = message(&arg(args, 0), "cURL", ctx)?; - host(ctx)?.borrow_mut().request(Request::LaunchUrl(u)); - Ok(JsValue::undefined()) -} - -/// What `app.execMenuItem(name)` asks for, for the menu items forms use: the side panels, Print -/// and Save As. Other items do nothing. -pub(crate) fn menu_item(name: &str) -> Option { - Some(match name { - "ShowHideFileAttachment" => Request::ShowPanel(Panel::Attachments), - "ShowHideBookmarks" => Request::ShowPanel(Panel::Bookmarks), - "ShowHideThumbnails" => Request::ShowPanel(Panel::Pages), - "ShowHideSignatures" => Request::ShowPanel(Panel::Signatures), - "ShowHideOptCont" => Request::ShowPanel(Panel::Layers), - "Print" => Request::Print, - // Save opens Save As too, as it always has for XFA forms' buttons: a click on a form - // never overwrites the file unasked. - "SaveAs" | "Save" => Request::SaveAs, - _ => return None, - }) -} - -/// `app.execMenuItem(name)`: queues the request [`menu_item`] gives for the name (Save opens -/// Save As too); an unknown name does nothing. -fn exec_menu_item(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { - let item = text(&arg(args, 0), ctx)?; - if let Some(r) = menu_item(&item) { - host(ctx)?.borrow_mut().request(r); - } - Ok(JsValue::undefined()) -} - -/// `app.response(…)`: always `null`, the answer for a canceled dialog, since no one is there to -/// type one. -fn response(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { - // No one to answer: as if the dialog was cancelled. - Ok(JsValue::null()) -} - -fn println(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { - let m = text_line(&arg(args, 0), ctx)?; - host(ctx)?.borrow_mut().print(m); - Ok(JsValue::undefined()) -} - -fn console_clear(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { - host(ctx)?.borrow_mut().console.clear(); - Ok(JsValue::undefined()) -} - -fn timer(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { - // Timers never fire in the sandbox; scripts get an object they can pass to clear*. - Ok(ObjectInitializer::new(ctx).build().into()) -} - -/// `util.printf(cFormat, …)`: `%[,n][+ 0#][width][.prec](d|f|s|x)`, where `,0` groups -/// thousands with commas (the default), `,1` doesn't, `,2` uses `.` and `,`, `,3` uses `.` -/// without grouping. -fn printf(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { - let fmt = text(&arg(args, 0), ctx)?; - let mut out = String::new(); - let check_output = |len: usize| -> JsResult<()> { - if len > MAX_PRINTF_OUTPUT_BYTES { - return Err(error(format!("util.printf output exceeds {MAX_PRINTF_OUTPUT_BYTES} bytes"))); - } - Ok(()) - }; - let mut next = 1; - let mut chars = fmt.chars().peekable(); - while let Some(c) = chars.next() { - if c != '%' { - check_output(out.len().saturating_add(c.len_utf8()))?; - out.push(c); - continue; - } - if chars.peek() == Some(&'%') { - chars.next(); - check_output(out.len().saturating_add(1))?; - out.push('%'); - continue; - } - let mut sep = 1u8; - if chars.peek() == Some(&',') { - chars.next(); - sep = chars.next().and_then(|d| d.to_digit(10)).unwrap_or(0) as u8; - } - let (mut plus, mut space, mut zero) = (false, false, false); - while let Some(&f) = chars.peek() { - match f { - '+' => plus = true, - ' ' => space = true, - '0' => zero = true, - '#' => {} - _ => break, - } - chars.next(); - } - let mut width = 0usize; - while let Some(d) = chars.peek().and_then(|d| d.to_digit(10)) { - width = width.saturating_mul(10).saturating_add(d as usize); - chars.next(); - } - let mut prec = None; - if chars.peek() == Some(&'.') { - chars.next(); - let mut p = 0usize; - while let Some(d) = chars.peek().and_then(|d| d.to_digit(10)) { - p = p.saturating_mul(10).saturating_add(d as usize); - chars.next(); - } - prec = Some(p); - } - if width > MAX_PRINTF_WIDTH_PRECISION || prec.is_some_and(|p| p > MAX_PRINTF_WIDTH_PRECISION) { - return Err(error(format!("util.printf width or precision exceeds {MAX_PRINTF_WIDTH_PRECISION}"))); - } - let conv = chars.next().unwrap_or('s'); - let v = arg(args, next); - next += 1; - let body = match conv { - 'd' | 'f' => { - let n = v.to_number(ctx)?; - let p = if conv == 'd' { 0 } else { prec.unwrap_or(6) }; - let n = if conv == 'd' { n.trunc() } else { n }; - let mut t = group(&format!("{:.*}", p, n.abs()), sep); - if n < 0.0 { - t.insert(0, '-'); - } else if plus { - t.insert(0, '+'); - } else if space { - t.insert(0, ' '); - } - t - } - 'x' => format!("{:X}", v.to_number(ctx)? as i64), - _ => { - let t = text(&v, ctx)?; - match prec { - Some(p) => t.chars().take(p).collect(), - None => t, - } - } - }; - let pad = width.saturating_sub(body.chars().count()); - check_output(out.len().saturating_add(pad).saturating_add(body.len()))?; - if zero && matches!(conv, 'd' | 'f') { - let (sign, digits) = if body.starts_with(['-', '+', ' ']) { body.split_at(1) } else { ("", body.as_str()) }; - out.push_str(sign); - out.push_str(&"0".repeat(pad)); - out.push_str(digits); - } else { - out.push_str(&" ".repeat(pad)); - out.push_str(&body); - } - } - Ok(s(&out)) -} - -/// Group the integer part of a plain decimal number per `util.printf`'s `,n` flag. -fn group(t: &str, sep: u8) -> String { - let (int, frac) = t.split_once('.').map_or((t, None), |(a, b)| (a, Some(b))); - let (thousands, point) = match sep { - 0 => (Some(','), '.'), - 2 => (Some('.'), ','), - 3 => (None, ','), - _ => (None, '.'), - }; - let mut g = String::new(); - for (i, ch) in int.chars().enumerate() { - if let Some(th) = thousands - && i > 0 - && (int.len() - i).is_multiple_of(3) - { - g.push(th); - } - g.push(ch); - } - if let Some(f) = frac { - g.push(point); - g.push_str(f); - } - g -} - -const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; -const DAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; - -/// `util.printd(cFormat, oDate)`: yyyy yy mmmm mmm mm m dddd ddd dd d HH H hh h MM M ss s tt, -/// and the numeric formats 0 (D:yyyymmddHHMMss), 1 (yyyy.mm.dd HH:MM:ss) and 2 (m/d/yy h:MM:ss tt). -fn printd(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { - let f = arg(args, 0); - let fmt = if f.is_number() { - match f.to_number(ctx)? as i32 { - 0 => "D:yyyymmddHHMMss".to_string(), - 1 => "yyyy.mm.dd HH:MM:ss".to_string(), - _ => "m/d/yy h:MM:ss tt".to_string(), - } - } else { - text(&f, ctx)? - }; - let d = arg(args, 1); - let Some(date) = d.as_object() else { return Err(error("util.printd needs a Date")) }; - let mut part = |m: &str| -> JsResult { - let func = date.get(JsString::from(m), ctx)?; - let func = func.as_callable().ok_or_else(|| error("util.printd needs a Date"))?; - let v = func.call(&d, &[], ctx)?.to_number(ctx)?; - if v.is_nan() { - return Err(error("invalid date")); - } - Ok(v as i64) - }; - let (y, mo, day, wd, h, mi, sec) = - (part("getFullYear")?, part("getMonth")?, part("getDate")?, part("getDay")?, part("getHours")?, part("getMinutes")?, part("getSeconds")?); - let mut out = String::new(); - let b = fmt.as_bytes(); - let mut i = 0; - while i < b.len() { - let c = b[i]; - // Only date letters read as runs; a literal is copied one character at a time, so - // measuring its run would rescan a long literal stretch at every position. - let run = if b"ymdHhMst".contains(&c) { b[i..].iter().take_while(|x| **x == c).count() } else { 1 }; - #[cfg(test)] - PRINTD_SCANNED.fetch_add(run as u64, std::sync::atomic::Ordering::Relaxed); - let push_num = |out: &mut String, v: i64, n: usize| { - if n >= 2 { - out.push_str(&format!("{v:02}")); - } else { - out.push_str(&v.to_string()); - } - }; - match c { - b'y' => { - if run >= 4 { - out.push_str(&format!("{y:04}")); - } else { - out.push_str(&format!("{:02}", y % 100)); - } - } - b'm' => match run { - r if r >= 4 => out.push_str(MONTHS[mo as usize % 12]), - 3 => out.push_str(&MONTHS[mo as usize % 12][..3]), - n => push_num(&mut out, mo + 1, n), - }, - b'd' => match run { - r if r >= 4 => out.push_str(DAYS[wd as usize % 7]), - 3 => out.push_str(&DAYS[wd as usize % 7][..3]), - n => push_num(&mut out, day, n), - }, - b'H' => push_num(&mut out, h, run), - b'h' => push_num(&mut out, if h % 12 == 0 { 12 } else { h % 12 }, run), - b'M' => push_num(&mut out, mi, run), - b's' => push_num(&mut out, sec, run), - b't' => { - let ampm = if h < 12 { "am" } else { "pm" }; - out.push_str(if run >= 2 { ampm } else { &m[..1] }); - } - b'\\' if i + 1 < b.len() => { - // The escaped character may be multi-byte; copy it whole. - let ch = fmt[i + 1..].chars().next().unwrap_or(' '); - out.push(ch); - i += 1 + ch.len_utf8(); - continue; - } - _ => { - // Copy the rest of a multi-byte character too. - let ch = fmt[i..].chars().next().unwrap_or(' '); - out.push(ch); - i += ch.len_utf8(); - continue; - } - } - i += run; - } - Ok(s(&out)) -} - -/// A field of a `util.scand` format. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum DateUnit { - /// `yyyy`: four digits. - Year, - /// `yy`: two digits, read as 1950–2049. - ShortYear, - Month, - /// `mmm`, `mmmm`: a month's name or its three-letter abbreviation. - MonthName, - Day, - /// `ddd`, `dddd`: a weekday's name, read and not checked. - Weekday, - Hour, - /// `h`, `hh`: 0–12, with `tt` for the afternoon. - Hour12, - Minute, - Second, - /// `t`, `tt`: am or pm. - AmPm, -} - -impl DateUnit { - /// A year, month or day: a format needs one of these for `util.scand` to read anything. - fn is_date(self) -> bool { - matches!(self, Self::Year | Self::ShortYear | Self::Month | Self::MonthName | Self::Day) - } - - /// Read from a run of digits, not from letters. - fn is_numeric(self) -> bool { - !matches!(self, Self::MonthName | Self::Weekday | Self::AmPm) - } -} - -/// A piece of a `util.scand` format. -#[derive(Debug, PartialEq, Eq)] -enum ScanPart { - /// A field, the digits it takes when typed together with the next one (`width`), and whether - /// the next one follows it with nothing between them in the format (`joined`). - Field { unit: DateUnit, width: usize, joined: bool }, - /// Letters the format writes as they are, which the text must have there. - Word(String), -} - -/// The fields and literal words of a `util.scand` format, in order. A numeric field is marked -/// `joined` when another numeric field follows it directly; anything but letters and digits -/// outside a field is left out. -fn scan_parts(fmt: &str) -> Vec { - let chars: Vec = fmt.chars().collect(); - let mut parts: Vec = Vec::new(); - // The last part is a field written right before this point, or letters still being collected. - let (mut after_field, mut in_word) = (false, false); - let mut i = 0; - while let Some(&c) = chars.get(i) { - // Counted for field letters only, so a long run of anything else is read once. - let run = if "ymdHhMst".contains(c) { chars.get(i..).unwrap_or_default().iter().take_while(|x| **x == c).count() } else { 1 }; - let field = match (c, run) { - ('y', 4..) => Some((DateUnit::Year, 4)), - ('y', _) => Some((DateUnit::ShortYear, 2)), - ('m', 3..) => Some((DateUnit::MonthName, 0)), - ('m', n) => Some((DateUnit::Month, n)), - ('d', 3..) => Some((DateUnit::Weekday, 0)), - ('d', n) => Some((DateUnit::Day, n)), - ('H', n) => Some((DateUnit::Hour, n.min(2))), - ('h', n) => Some((DateUnit::Hour12, n.min(2))), - ('M', n) => Some((DateUnit::Minute, n.min(2))), - ('s', n) => Some((DateUnit::Second, n.min(2))), - ('t', _) => Some((DateUnit::AmPm, 0)), - _ => None, - }; - if let Some((unit, width)) = field { - if after_field - && unit.is_numeric() - && let Some(ScanPart::Field { unit: last, joined, .. }) = parts.last_mut() - && last.is_numeric() - { - *joined = true; - } - parts.push(ScanPart::Field { unit, width, joined: false }); - (after_field, in_word) = (true, false); - i += run; - continue; - } - // `\` writes the next character as it is. - let (literal, step) = if c == '\\' { (chars.get(i + 1).copied(), 2) } else { (Some(c), 1) }; - i += step; - after_field = false; - match literal { - Some(l) if l.is_alphanumeric() => match parts.last_mut() { - Some(ScanPart::Word(w)) if in_word => w.push(l), - _ => { - parts.push(ScanPart::Word(l.to_string())); - in_word = true; - } - }, - _ => in_word = false, - } - } - parts -} - -/// A run of digits or of letters in the text `util.scand` reads. -enum ScanToken<'a> { - Digits(&'a str), - Letters(&'a str), -} - -/// The text's runs of digits and of letters, in order; anything else only separates them, and -/// may not begin or end the text. `None` when it does. -fn scan_tokens(text: &str) -> Option>> { - // Some(true) for a digit, Some(false) for a letter, None for a separator. - let kind = |c: char| (c.is_ascii_digit() || c.is_alphabetic()).then_some(c.is_ascii_digit()); - kind(text.chars().next()?)?; - kind(text.chars().next_back()?)?; - let mut out = Vec::new(); - let mut start: Option<(usize, bool)> = None; - for (at, c) in text.char_indices().chain(std::iter::once((text.len(), ' '))) { - let k = kind(c); - if let Some((from, digits)) = start - && k != Some(digits) - { - let piece = text.get(from..at)?; - out.push(if digits { ScanToken::Digits(piece) } else { ScanToken::Letters(piece) }); - start = None; - } - if let Some(digits) = k - && start.is_none() - { - start = Some((at, digits)); - } - } - Some(out) -} - -/// What `util.scand` reads. -#[derive(Debug, PartialEq, Eq)] -enum Scanned { - /// The text is empty: today, at noon. - Today, - /// A date and time: year, month (1–12), day, hours, minutes, seconds. - At(i32, u32, u32, u32, u32, u32), -} - -/// The fields `util.scand` has read so far. -#[derive(Default)] -struct ScanValues { - y: Option, - mo: Option, - d: Option, - h: Option, - mi: Option, - sec: Option, - pm: Option, - /// The hour was read from `h` or `hh`. - twelve: bool, -} - -impl ScanValues { - /// Records `v` as the value of `unit`: a `yy` year as 1950–2049, and an `h` or `hh` hour - /// marked as 12-hour. Month names, weekdays and am/pm are not read here. - fn set(&mut self, unit: DateUnit, v: u32) { - match unit { - DateUnit::Year => self.y = i32::try_from(v).ok(), - DateUnit::ShortYear => self.y = i32::try_from(v).ok().map(|v| if v < 50 { 2000 + v } else { 1900 + v }), - DateUnit::Month => self.mo = Some(v), - DateUnit::Day => self.d = Some(v), - DateUnit::Hour => self.h = Some(v), - DateUnit::Hour12 => { - self.h = Some(v); - self.twelve = true; - } - DateUnit::Minute => self.mi = Some(v), - DateUnit::Second => self.sec = Some(v), - DateUnit::MonthName | DateUnit::Weekday | DateUnit::AmPm => {} - } - } -} - -/// Reads `text` by a `util.scand` format, as another viewer was observed to (black-box): -/// -/// - Fields may be separated by anything but digits and letters, whether or not the format -/// separates them, but nothing may come before the first or after the last. -/// - Digits typed together fill the format's fields written together with exactly as many -/// digits as each has letters (`yyyymd`: 4, 1 and 1). A run of digits for one field alone may -/// have one or two, except a four-digit year. -/// - `yy` is 1950–2049; month names may be written in full or as three letters, in any case. -/// - Impossible dates are refused, but a month or day of 0 is read as 1. -/// - A format with no year, month or day reads nothing; an empty text is today, at noon. -/// -/// Not observed: a year left out is `this_year`, and a month or day left out is 1. -fn scan_date(fmt: &str, text: &str, this_year: i32) -> Option { - let parts = scan_parts(fmt); - if !parts.iter().any(|p| matches!(p, ScanPart::Field { unit, .. } if unit.is_date())) { - return None; - } - if text.is_empty() { - return Some(Scanned::Today); - } - let tokens = scan_tokens(text)?; - let mut v = ScanValues::default(); - let (mut p, mut t) = (0, 0); - while let Some(part) = parts.get(p) { - let token = tokens.get(t)?; - t += 1; - match (part, token) { - (ScanPart::Word(w), ScanToken::Letters(l) | ScanToken::Digits(l)) => { - if w.to_lowercase() != l.to_lowercase() { - return None; - } - p += 1; - } - (ScanPart::Field { unit: DateUnit::MonthName, .. }, ScanToken::Letters(l)) => { - v.mo = Some(named(&MONTHS, l)?.saturating_add(1)); - p += 1; - } - (ScanPart::Field { unit: DateUnit::Weekday, .. }, ScanToken::Letters(l)) => { - named(&DAYS, l)?; - p += 1; - } - (ScanPart::Field { unit: DateUnit::AmPm, .. }, ScanToken::Letters(l)) => { - v.pm = Some(match l.to_lowercase().as_str() { - "am" | "a" => false, - "pm" | "p" => true, - _ => return None, - }); - p += 1; - } - (ScanPart::Field { unit, width, joined }, ScanToken::Digits(run)) if unit.is_numeric() => { - // The fields written together from here, no more than the run's digits can fill, - // and the fewest of them the run fills. - let mut group = vec![(*unit, *width)]; - let (mut more, mut widths) = (*joined, *width); - while more - && widths < run.len() - && let Some(ScanPart::Field { unit, width, joined }) = parts.get(p + group.len()) - { - group.push((*unit, *width)); - widths = widths.saturating_add(*width); - more = *joined; - } - let mut sum = 0; - let fills = group.iter().position(|(_, w)| { - sum += w; - sum == run.len() - }); - if let Some(last) = fills { - let mut at = 0; - for (unit, w) in group.iter().take(last + 1) { - v.set(*unit, run.get(at..at + w)?.parse().ok()?); - at += w; - } - p += last + 1; - } else if *unit != DateUnit::Year && run.len() <= 2 { - v.set(*unit, run.parse().ok()?); - p += 1; - } else { - return None; - } - } - _ => return None, - } - } - if t != tokens.len() { - return None; - } - let y = v.y.unwrap_or(this_year); - let mo = v.mo.unwrap_or(1).max(1); - let d = v.d.unwrap_or(1).max(1); - if mo > 12 || i64::from(d) > i64::from(formcalc::days_in(i64::from(y), mo)) { - return None; - } - let mut h = v.h.unwrap_or(0); - if v.twelve && h > 12 { - return None; - } - match v.pm { - Some(true) if h < 12 => h += 12, - Some(false) if h == 12 => h = 0, - _ => {} - } - let (mi, sec) = (v.mi.unwrap_or(0), v.sec.unwrap_or(0)); - (h <= 23 && mi <= 59 && sec <= 59).then_some(Scanned::At(y, mo, d, h, mi, sec)) -} - -/// The index of the name `word` is, in full or as its first three letters, in any case. -fn named(names: &[&str], word: &str) -> Option { - let word = word.to_lowercase(); - let i = names.iter().position(|n| { - let n = n.to_lowercase(); - word == n || n.get(..3) == Some(word.as_str()) - })?; - u32::try_from(i).ok() -} - -/// `util.scand(cFormat, cDate)`: the date `cDate` gives by a `util.printd` format, as a local -/// `Date`, or `null` when it doesn't read as one (see [`scan_date`]). Only text is read. -fn scand(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { - let Some(typed) = arg(args, 1).as_string().map(|t| t.to_std_string_escaped()) else { - return Ok(JsValue::null()); - }; - let fmt = text(&arg(args, 0), ctx)?; - let date = JsDate::new(ctx); - let this_year = date.get_full_year(ctx)?.to_number(ctx)?; - let this_year = if this_year.is_finite() { this_year as i32 } else { 2000 }; - match scan_date(&fmt, &typed, this_year) { - None => return Ok(JsValue::null()), - Some(Scanned::Today) => { - date.set_hours(&[12.into(), 0.into(), 0.into(), 0.into()], ctx)?; - } - Some(Scanned::At(y, mo, d, h, mi, sec)) => { - // setFullYear, not the constructor, so years 0–99 stay as they are. - date.set_full_year(&[y.into(), (mo.saturating_sub(1)).into(), d.into()], ctx)?; - date.set_hours(&[h.into(), mi.into(), sec.into(), 0.into()], ctx)?; - } - } - Ok(date.into()) -} - -/// `util.printx(cFormat, cSource)`: `?` any character, `X` letter or digit, `A` letter, `9` -/// digit, `*` the rest, `\` escape, `>` / `<` / `=` upper / lower / as is. -fn printx(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { - let fmt: Vec = text(&arg(args, 0), ctx)?.chars().collect(); - let src: Vec = text(&arg(args, 1), ctx)?.chars().collect(); - let (mut out, mut j, mut case) = (String::new(), 0, 0); - let mut i = 0; - let push = |out: &mut String, c: char, case: i32| match case { - 1 => out.extend(c.to_uppercase()), - -1 => out.extend(c.to_lowercase()), - _ => out.push(c), - }; - while i < fmt.len() { - let f = fmt[i]; - i += 1; - let take = |j: &mut usize, ok: &dyn Fn(char) -> bool| -> Option { - while *j < src.len() { - let c = src[*j]; - *j += 1; - if ok(c) { - return Some(c); - } - } - None - }; - match f { - '?' => { - if let Some(c) = take(&mut j, &|_| true) { - push(&mut out, c, case) - } - } - 'X' => { - if let Some(c) = take(&mut j, &|c| c.is_alphanumeric()) { - push(&mut out, c, case) - } - } - 'A' => { - if let Some(c) = take(&mut j, &|c| c.is_alphabetic()) { - push(&mut out, c, case) - } - } - '9' => { - if let Some(c) = take(&mut j, &|c| c.is_ascii_digit()) { - push(&mut out, c, case) - } - } - '*' => { - for c in src[j.min(src.len())..].iter().copied() { - push(&mut out, c, case); - } - j = src.len(); - } - '\\' if i < fmt.len() => { - out.push(fmt[i]); - i += 1; - } - '>' => case = 1, - '<' => case = -1, - '=' => case = 0, - c => out.push(c), - } - } - Ok(s(&out)) -} - -/// The `color` object: each named color (`transparent`, `black`, `red`, `cyan`, `gray`, …) as -/// an array of its color space (`T`, `G`, `RGB` or `CMYK`) followed by its components. -fn color_object(ctx: &mut Context) -> JsObject { - let mut entries: Vec<(&str, Vec)> = vec![ - ("transparent", vec![s("T")]), - ("black", vec![s("G"), 0.into()]), - ("white", vec![s("G"), 1.into()]), - ("red", vec![s("RGB"), 1.into(), 0.into(), 0.into()]), - ("green", vec![s("RGB"), 0.into(), 1.into(), 0.into()]), - ("blue", vec![s("RGB"), 0.into(), 0.into(), 1.into()]), - ("cyan", vec![s("CMYK"), 1.into(), 0.into(), 0.into(), 0.into()]), - ("magenta", vec![s("CMYK"), 0.into(), 1.into(), 0.into(), 0.into()]), - ("yellow", vec![s("CMYK"), 0.into(), 0.into(), 1.into(), 0.into()]), - ("dkGray", vec![s("G"), JsValue::from(0.25)]), - ("gray", vec![s("G"), JsValue::from(0.5)]), - ("ltGray", vec![s("G"), JsValue::from(0.75)]), - ]; - let arrays: Vec<(&str, JsValue)> = entries.drain(..).map(|(k, v)| (k, JsArray::from_iter(v, ctx).into())).collect(); - let mut o = ObjectInitializer::new(ctx); - for (k, v) in arrays { - o.property(JsString::from(k), v, Attribute::all()); - } - o.build() -} - -/// The XFA version scripts see as `xfa_version`. -const XFA_VERSION: f64 = 3.5; - -/// Defines the form object model's globals: the document's functions (`getField`, `resetForm`, -/// `print`, `submitForm` and others) and properties (`numFields`, `pageNum`, `numPages`, -/// `documentFileName`, `info`, the XFA version), and the `app`, `util`, `console`, `display` and -/// `color` objects. -fn install(ctx: &mut Context) -> JsResult<()> { - let globals: [(&str, usize, NativeFunction); 9] = [ - ("getField", 1, NativeFunction::from_fn_ptr(get_field)), - ("getNthFieldName", 1, NativeFunction::from_fn_ptr(get_nth_field_name)), - ("resetForm", 1, NativeFunction::from_fn_ptr(reset_form)), - ("print", 1, NativeFunction::from_fn_ptr(print)), - ("submitForm", 1, NativeFunction::from_fn_ptr(submit_form)), - ("mailDoc", 1, NativeFunction::from_fn_ptr(noop)), - ("calculateNow", 0, NativeFunction::from_fn_ptr(noop)), - ("syncAnnotScan", 0, NativeFunction::from_fn_ptr(noop)), - ("dirty", 0, NativeFunction::from_fn_ptr(noop)), - ]; - for (name, len, f) in globals { - ctx.register_global_callable(JsString::from(name), len, f)?; - } - let global = ctx.global_object(); - let rw = Attribute::CONFIGURABLE; - let nf = function(ctx, NativeFunction::from_fn_ptr(num_fields)); - let pg = function(ctx, NativeFunction::from_fn_ptr(page_num_get)); - let ps = function(ctx, NativeFunction::from_fn_ptr(page_num_set)); - let (file, pages, info, xfa) = { - let h = host(ctx)?.borrow(); - (h.doc.file_name.clone(), h.doc.num_pages, h.doc.info.clone(), h.doc.xfa) - }; - let mut io = ObjectInitializer::new(ctx); - for (k, v) in &info { - io.property(JsString::from(k.as_str()), s(v), Attribute::all()); - } - let info = io.build(); - use boa_engine::property::PropertyDescriptor; - global.define_property_or_throw(js_string!("numFields"), PropertyDescriptor::builder().get(nf).configurable(true), ctx)?; - global.define_property_or_throw(js_string!("pageNum"), PropertyDescriptor::builder().get(pg).set(ps).configurable(true), ctx)?; - ctx.register_global_property(js_string!("numPages"), JsValue::from(pages as f64), rw)?; - ctx.register_global_property(js_string!("documentFileName"), s(&file), rw)?; - ctx.register_global_property(js_string!("info"), info, rw)?; - // XFA forms check both before they run: every document sees the XFA version, and only an - // XFA form sees `xfa_installed`. - ctx.register_global_property(js_string!("xfa_version"), JsValue::from(XFA_VERSION), rw)?; - if xfa { - ctx.register_global_property(js_string!("xfa_installed"), JsValue::from(true), rw)?; - } - - let app = ObjectInitializer::new(ctx) - .function(NativeFunction::from_fn_ptr(alert), js_string!("alert"), 1) - .function(NativeFunction::from_fn_ptr(beep), js_string!("beep"), 0) - .function(NativeFunction::from_fn_ptr(response), js_string!("response"), 1) - .function(NativeFunction::from_fn_ptr(launch_url), js_string!("launchURL"), 2) - .function(NativeFunction::from_fn_ptr(exec_menu_item), js_string!("execMenuItem"), 1) - .function(NativeFunction::from_fn_ptr(timer), js_string!("setTimeOut"), 2) - .function(NativeFunction::from_fn_ptr(timer), js_string!("setInterval"), 2) - .function(NativeFunction::from_fn_ptr(noop), js_string!("clearTimeOut"), 1) - .function(NativeFunction::from_fn_ptr(noop), js_string!("clearInterval"), 1) - .property(js_string!("viewerType"), s("Exchange-Pro"), Attribute::READONLY) - .property(js_string!("viewerVariation"), s("Full"), Attribute::READONLY) - .property(js_string!("viewerVersion"), JsValue::from(24.0), Attribute::READONLY) - .property(js_string!("formsVersion"), JsValue::from(24.0), Attribute::READONLY) - .property(js_string!("platform"), s(platform()), Attribute::READONLY) - .property(js_string!("language"), s("ENU"), Attribute::READONLY) - .build(); - ctx.register_global_property(js_string!("app"), app, rw)?; - let util = ObjectInitializer::new(ctx) - .function(NativeFunction::from_fn_ptr(printf), js_string!("printf"), 1) - .function(NativeFunction::from_fn_ptr(printd), js_string!("printd"), 2) - .function(NativeFunction::from_fn_ptr(printx), js_string!("printx"), 2) - .function(NativeFunction::from_fn_ptr(scand), js_string!("scand"), 2) - .build(); - ctx.register_global_property(js_string!("util"), util, rw)?; - let console = ObjectInitializer::new(ctx) - .function(NativeFunction::from_fn_ptr(println), js_string!("println"), 1) - .function(NativeFunction::from_fn_ptr(noop), js_string!("show"), 0) - .function(NativeFunction::from_fn_ptr(noop), js_string!("hide"), 0) - .function(NativeFunction::from_fn_ptr(console_clear), js_string!("clear"), 0) - .build(); - ctx.register_global_property(js_string!("console"), console, rw)?; - let display = ObjectInitializer::new(ctx) - .property(js_string!("visible"), DISPLAY_VISIBLE, Attribute::READONLY) - .property(js_string!("hidden"), DISPLAY_HIDDEN, Attribute::READONLY) - .property(js_string!("noPrint"), DISPLAY_NO_PRINT, Attribute::READONLY) - .property(js_string!("noView"), DISPLAY_NO_VIEW, Attribute::READONLY) - .build(); - ctx.register_global_property(js_string!("display"), display, rw)?; - let color = color_object(ctx); - ctx.register_global_property(js_string!("color"), color, rw)?; - Ok(()) -} - -fn platform() -> &'static str { - if cfg!(target_os = "macos") { - "MAC" - } else if cfg!(target_os = "windows") { - "WIN" - } else { - "UNIX" - } -} - -fn event_object(ctx: &mut Context, e: &Event) -> JsObject { - let target = e.target.as_ref().map_or(JsValue::null(), |t| field_object(ctx, t).into()); - ObjectInitializer::new(ctx) - .property(js_string!("name"), s(&e.name), Attribute::all()) - .property(js_string!("type"), s(&e.kind), Attribute::all()) - .property(js_string!("value"), s(&e.value), Attribute::all()) - .property(js_string!("change"), s(&e.change), Attribute::all()) - .property(js_string!("changeEx"), s(&e.change), Attribute::all()) - .property(js_string!("willCommit"), e.will_commit, Attribute::all()) - .property(js_string!("rc"), true, Attribute::all()) - .property(js_string!("target"), target, Attribute::all()) - .property(js_string!("targetName"), s(e.target.as_deref().unwrap_or("")), Attribute::all()) - .property(js_string!("selStart"), 0, Attribute::all()) - .property(js_string!("selEnd"), 0, Attribute::all()) - .property(js_string!("commitKey"), 0, Attribute::all()) - .property(js_string!("modifier"), false, Attribute::all()) - .property(js_string!("shift"), false, Attribute::all()) - .build() -} - -// ── stack safety ──────────────────────────────────────────────────────────────────────────── -// -// boa's parser and compiler recurse once per nesting level and have no limit of their own: about -// a hundred nested parentheses overflow a 2 MiB stack, which aborts the whole app. Scripts come -// from documents, so `run` refuses nesting no real form needs and runs the engine on a thread -// with a large stack (address space, committed only as it is used). Together these bound the -// stack a script can use: brackets and prefix operators by the limits below, and every other -// construct (a few KiB per level, several bytes of source each) by the length limit. - -/// Bytes `util.printd` has scanned for runs of a format character, for tests that bound its work. #[cfg(test)] -pub(crate) static PRINTD_SCANNED: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); - -/// Longest script, in bytes. -pub(crate) const MAX_SCRIPT_BYTES: usize = 256 * 1024; -/// Largest width or precision accepted by `util.printf`. -const MAX_PRINTF_WIDTH_PRECISION: usize = 4096; -/// Longest `util.printf` output, in bytes. -const MAX_PRINTF_OUTPUT_BYTES: usize = 1 << 20; -/// Longest array read item by item (`resetForm`, a field's `value` or colours). A sparse array's -/// length costs the script nothing but the host one string per item; real lists are tiny. -const MAX_ARRAY_LENGTH: u64 = 1 << 20; -/// Deepest nesting of `(`, `[` and `{`. -const MAX_BRACKET_DEPTH: usize = 64; -/// Longest run of prefix operators (`!`, `~`, `+`, `-`). -const MAX_PREFIX_RUN: usize = 64; -/// Most `?` (conditional expressions nest without brackets). -const MAX_CONDITIONALS: usize = 4096; -/// The script thread's stack. +pub(crate) use format::PRINTD_SCANNED; +use format::{printd, printf, printx, scand}; +pub(crate) use globals::menu_item; +use globals::platform; +use host::{arg, as_number, error, function, s, text}; +pub use model::{ + DISPLAY_HIDDEN, DISPLAY_NO_PRINT, DISPLAY_NO_VIEW, DISPLAY_VISIBLE, DocInfo, Event, FieldState, FieldType, Limits, Message, MessageButtons, + MessageIcon, Outcome, Panel, Request, +}; #[cfg(not(target_arch = "wasm32"))] -const SCRIPT_STACK: usize = 1 << 30; - -/// Why `source` is refused, if it is: too long, or nested deeper than any form needs. Strings -/// and comments are skipped; brackets in regular expressions and templates count, which only -/// errs on the safe side. -fn refuse(source: &str) -> Option { - if source.len() > MAX_SCRIPT_BYTES { - return Some(format!("the script is too long ({} KiB; the limit is {} KiB)", source.len() / 1024, MAX_SCRIPT_BYTES / 1024)); - } - let too_deep = || Some("the script is nested too deeply to run safely".to_string()); - let b = source.as_bytes(); - let (mut depth, mut prefix, mut conditionals) = (0usize, 0usize, 0usize); - let mut i = 0; - while let Some(&c) = b.get(i) { - i += 1; - match c { - b'\'' | b'"' => { - // To the closing quote (or the end of the line, where an unclosed string ends). - while let Some(&d) = b.get(i) { - i += 1; - if d == b'\\' { - i += 1; - } else if d == c || d == b'\n' { - break; - } - } - prefix = 0; - } - b'/' if b.get(i) == Some(&b'/') => { - while b.get(i).is_some_and(|d| *d != b'\n') { - i += 1; - } - } - b'/' if b.get(i) == Some(&b'*') => { - i += 1; - while i < b.len() && !b[i..].starts_with(b"*/") { - i += 1; - } - i += 2; - } - b'(' | b'[' | b'{' => { - depth += 1; - if depth > MAX_BRACKET_DEPTH { - return too_deep(); - } - prefix = 0; - } - b')' | b']' | b'}' => { - depth = depth.saturating_sub(1); - prefix = 0; - } - b'!' | b'~' | b'+' | b'-' => { - prefix += 1; - if prefix > MAX_PREFIX_RUN { - return too_deep(); - } - } - b'?' => { - conditionals += 1; - if conditionals > MAX_CONDITIONALS { - return too_deep(); - } - prefix = 0; - } - c if c.is_ascii_whitespace() => {} - _ => prefix = 0, - } - } - None -} - -/// Run `script` for `event`, after the document-level scripts (`doc_scripts`, which define the -/// functions field scripts call), with the form's `fields`. -pub fn run(script: &str, event: &Event, doc: &DocInfo, fields: &[FieldState], doc_scripts: &[String], limits: Limits) -> Outcome { - run_shared(script, event, doc, Arc::new(fields.to_vec()), doc_scripts, limits) -} - -/// Like [`run`], sharing an immutable initial field snapshot. Scripts still get a fresh -/// context; the first field write copies the snapshot, so neither the caller nor another -/// script can observe uncommitted writes. Read-only calculations avoid copying the whole form. -pub fn run_shared(script: &str, event: &Event, doc: &DocInfo, fields: Arc>, doc_scripts: &[String], limits: Limits) -> Outcome { - let failed = |why: String| Outcome { rc: true, value: event.value.clone(), change: event.change.clone(), error: Some(why), ..Default::default() }; - if let Some(why) = refuse(script) { - return failed(why); - } - // A document script too long or too deeply nested to run safely is skipped and reported, - // as one that throws is; the others and the script still run (#3940). - let mut skipped = Vec::new(); - let kept: Vec<(usize, &str)> = doc_scripts - .iter() - .enumerate() - .filter_map(|(i, d)| match refuse(d) { - Some(why) => { - skipped.push((i, why)); - None - } - None => Some((i, d.as_str())), - }) - .collect(); - let (kept, skipped) = (&kept, &skipped); - #[cfg(not(target_arch = "wasm32"))] - { - std::thread::scope(|scope| { - let spawned = std::thread::Builder::new() - .name("pdfcraft-js".into()) - .stack_size(SCRIPT_STACK) - .spawn_scoped(scope, || run_here(script, event, doc, fields, kept, skipped, limits)); - match spawned { - Ok(thread) => thread.join().unwrap_or_else(|_| failed("the script stopped with an internal error".into())), - Err(e) => failed(format!("the script engine could not start: {e}")), - } - }) - } - #[cfg(target_arch = "wasm32")] - run_here(script, event, doc, fields, kept, skipped, limits) -} - -/// Host hooks that refuse to compile code from strings (`eval`, `Function` and its generator -/// and async kin). Such code is built at run time, so it never passed [`refuse`], and boa's -/// recursive parser would overflow the stack on a deeply nested one. -struct NoCodeFromStrings; - -impl HostHooks for NoCodeFromStrings { - fn ensure_can_compile_strings(&self, _: Realm, _: &[JsString], _: &JsString, _: bool, _: &mut Context) -> JsResult<()> { - Err(JsNativeError::typ().with_message("scripts can't compile code from strings (eval, Function)").into()) - } -} - -/// Host hooks that hold code compiled from strings to the checks a script passes ([`refuse`]), -/// for runners that start the script itself through `eval`. -pub(crate) struct CheckedCodeFromStrings; - -impl HostHooks for CheckedCodeFromStrings { - fn ensure_can_compile_strings(&self, _: Realm, parameters: &[JsString], body: &JsString, _: bool, _: &mut Context) -> JsResult<()> { - let units = parameters.iter().chain(std::iter::once(body)).fold(0usize, |n, s| n.saturating_add(s.len())); - if units > MAX_SCRIPT_BYTES { - return Err(JsNativeError::range().with_message("the code is too long to compile").into()); - } - // As one text: brackets left open in one parameter carry into the next. - let mut source = parameters.iter().map(JsString::to_std_string_lossy).collect::>().join(","); - source.push('\n'); - source.push_str(&body.to_std_string_lossy()); - match refuse(&source) { - Some(why) => Err(JsNativeError::range().with_message(why).into()), - None => Ok(()), - } - } -} - -/// How [`Outcome::doc_errors`] names a failing document script: by its 1-based place. -fn doc_error(index: usize, error: &dyn std::fmt::Display) -> String { - format!("document script {}: {error}", index.saturating_add(1)) -} - -/// `doc_scripts` are the document scripts to run with their places among all of them; -/// `skipped` the ones refused before running, with the reason. -fn run_here( - script: &str, - event: &Event, - doc: &DocInfo, - fields: Arc>, - doc_scripts: &[(usize, &str)], - skipped: &[(usize, String)], - limits: Limits, -) -> Outcome { - let mut ctx = match Context::builder().host_hooks(std::rc::Rc::new(NoCodeFromStrings)).build() { - Ok(ctx) => ctx, - Err(e) => { - let error = Some(format!("the script engine could not start: {e}")); - return Outcome { rc: true, value: event.value.clone(), change: event.change.clone(), error, ..Default::default() }; - } - }; - ctx.runtime_limits_mut().set_loop_iteration_limit(limits.loop_iterations); - ctx.runtime_limits_mut().set_recursion_limit(limits.recursion); - ctx.insert_data(RefCell::new(Host { - fields, - changed: BTreeSet::new(), - alerts: Vec::new(), - console: Vec::new(), - requests: Vec::new(), - doc: doc.clone(), - kept: 0, - dropped: 0, - })); - let mut out = Outcome { rc: true, value: event.value.clone(), change: event.change.clone(), ..Default::default() }; - let mut doc_errors: Vec<(usize, String)> = skipped.iter().map(|(i, why)| (*i, doc_error(*i, why))).collect(); - let result = (|| -> JsResult<()> { - install(&mut ctx)?; - let ev = event_object(&mut ctx, event); - ctx.register_global_property(js_string!("event"), ev.clone(), Attribute::all())?; - // One document script that throws (calling a method PdfCraft lacks, say) must not keep - // every field script in the form from running: what it defined before the error, and - // the other scripts, stay available (#3940). - for &(i, d) in doc_scripts { - if let Err(e) = ctx.eval(Source::from_bytes(d)) { - doc_errors.push((i, doc_error(i, &e))); - } - } - let r = ctx.eval(Source::from_bytes(script))?; - if !r.is_undefined() { - out.result = Some(text(&r, &mut ctx)?); - } - let rc = ev.get(js_string!("rc"), &mut ctx)?; - out.rc = rc.to_boolean(); - let v = ev.get(js_string!("value"), &mut ctx)?; - out.value = text(&v, &mut ctx)?; - let c = ev.get(js_string!("change"), &mut ctx)?; - out.change = text(&c, &mut ctx)?; - Ok(()) - })(); - if let Err(e) = result { - out.error = Some(e.to_string()); - } - doc_errors.sort_by_key(|(i, _)| *i); - out.doc_errors = doc_errors.into_iter().map(|(_, e)| e).collect(); - if let Some(h) = ctx.remove_data::() { - let h = h.into_inner(); - out.changed = h.changed.iter().map(|i| h.fields[*i].clone()).collect(); - out.alerts = h.alerts; - out.console = h.console; - out.requests = h.requests; - if h.dropped > 0 { - out.console.push(format!("the script's messages, console lines and requests passed their limits; {} more were left out", h.dropped)); - } - } - out -} +use run::SCRIPT_STACK; +use run::refuse; +pub(crate) use run::{CheckedCodeFromStrings, MAX_SCRIPT_BYTES}; +pub use run::{run, run_shared}; pub mod formcalc; pub mod xfa; diff --git a/crates/js/src/model.rs b/crates/js/src/model.rs new file mode 100644 index 0000000000..36b09bec97 --- /dev/null +++ b/crates/js/src/model.rs @@ -0,0 +1,305 @@ +/// The kinds of field, as `field.type` names them. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FieldType { + Text, + CheckBox, + RadioButton, + ComboBox, + ListBox, + Button, + Signature, +} + +impl FieldType { + pub fn name(self) -> &'static str { + match self { + FieldType::Text => "text", + FieldType::CheckBox => "checkbox", + FieldType::RadioButton => "radiobutton", + FieldType::ComboBox => "combobox", + FieldType::ListBox => "listbox", + FieldType::Button => "button", + FieldType::Signature => "signature", + } + } +} + +/// `display.*`: visible, hidden, noPrint, noView. +pub const DISPLAY_VISIBLE: i32 = 0; + +pub const DISPLAY_HIDDEN: i32 = 1; + +pub const DISPLAY_NO_PRINT: i32 = 2; + +pub const DISPLAY_NO_VIEW: i32 = 3; + +/// A field as scripts see it. +#[derive(Clone, Debug, PartialEq)] +pub struct FieldState { + pub name: String, + pub kind: FieldType, + /// Text: the text. Check box / radio: the selected export value (empty when off). Choice: + /// the selected export values. + pub value: Vec, + pub default: Vec, + pub readonly: bool, + pub required: bool, + pub display: i32, + /// Choice options (export value, display text); check boxes and radios: their export + /// values, one per widget. + pub options: Vec<(String, String)>, + pub char_limit: Option, + /// `textColor` / `fillColor` as Acrobat colour arrays (`["RGB", r, g, b]` etc.), when set. + pub text_color: Option>, + pub fill_color: Option>, +} + +impl FieldState { + pub fn new(name: impl Into, kind: FieldType, value: Vec) -> FieldState { + FieldState { + name: name.into(), + kind, + value, + default: Vec::new(), + readonly: false, + required: false, + display: DISPLAY_VISIBLE, + options: Vec::new(), + char_limit: None, + text_color: None, + fill_color: None, + } + } + + pub(crate) fn single(&self) -> String { + self.value.first().cloned().unwrap_or_default() + } +} + +/// The event a script runs for. +#[derive(Clone, Debug, PartialEq)] +pub struct Event { + /// `Keystroke`, `Validate`, `Calculate`, `Format`, `Mouse Up`, `Open`, … + pub name: String, + /// `Field`, `Doc`, `Page`, … + pub kind: String, + /// The field the event is for. + pub target: Option, + pub value: String, + pub change: String, + pub will_commit: bool, +} + +impl Event { + /// A field event (`event.type` = "Field"). + pub fn field(name: &str, target: &str, value: &str) -> Event { + Event { name: name.into(), kind: "Field".into(), target: Some(target.into()), value: value.into(), change: String::new(), will_commit: true } + } + + /// A document event (`event.type` = "Doc"), e.g. Open or WillSave. + pub fn doc(name: &str) -> Event { + Event { name: name.into(), kind: "Doc".into(), target: None, value: String::new(), change: String::new(), will_commit: false } + } +} + +/// About the document, for `this.numPages`, `this.info` and the like. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct DocInfo { + pub file_name: String, + pub num_pages: usize, + /// The current page, 0-based. + pub page: usize, + /// Document information (`this.info.title` etc.): lower-case key, value. + pub info: Vec<(String, String)>, + /// The document has an XFA form, so scripts see `xfa_installed`. + pub xfa: bool, +} + +/// The icon a script's message shows: `app.alert`'s `nIcon` and `xfa.host.messageBox`'s +/// icon, numbered 0 to 3 in the JavaScript for Acrobat API Reference and the XFA +/// specification alike. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum MessageIcon { + /// 0, the default. + #[default] + Error, + /// 1. + Warning, + /// 2. + Question, + /// 3. + Status, +} + +impl MessageIcon { + /// The icon numbered `code`; a missing, fractional or out-of-range number is the default. + pub fn from_code(code: Option) -> MessageIcon { + match whole_code(code) { + Some(1) => MessageIcon::Warning, + Some(2) => MessageIcon::Question, + Some(3) => MessageIcon::Status, + _ => MessageIcon::Error, + } + } + + /// A stable name for tools and logs: `error`, `warning`, `question` or `status`. + pub fn name(self) -> &'static str { + match self { + MessageIcon::Error => "error", + MessageIcon::Warning => "warning", + MessageIcon::Question => "question", + MessageIcon::Status => "status", + } + } +} + +/// The buttons a script's message offers: `app.alert`'s `nType` and `xfa.host.messageBox`'s +/// buttons, numbered 0 to 3 in both references. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum MessageButtons { + /// 0, the default. + #[default] + Ok, + /// 1. + OkCancel, + /// 2. + YesNo, + /// 3. + YesNoCancel, +} + +impl MessageButtons { + /// The buttons numbered `code`; a missing, fractional or out-of-range number is the default. + pub fn from_code(code: Option) -> MessageButtons { + match whole_code(code) { + Some(1) => MessageButtons::OkCancel, + Some(2) => MessageButtons::YesNo, + Some(3) => MessageButtons::YesNoCancel, + _ => MessageButtons::Ok, + } + } + + /// A stable name for tools and logs: `ok`, `ok_cancel`, `yes_no` or `yes_no_cancel`. + pub fn name(self) -> &'static str { + match self { + MessageButtons::Ok => "ok", + MessageButtons::OkCancel => "ok_cancel", + MessageButtons::YesNo => "yes_no", + MessageButtons::YesNoCancel => "yes_no_cancel", + } + } +} + +/// `code` as one of the numbers 0 to 3 the references define, when it is exactly one. +fn whole_code(code: Option) -> Option { + let c = code.filter(|c| c.is_finite() && c.fract() == 0.0 && (0.0..=3.0).contains(c))?; + // In 0..=3 and whole, so the cast is exact. + Some(c as u8) +} + +/// A message a script showed (`app.alert`, `xfa.host.messageBox`), with the title, icon and +/// buttons it asked for. Its text and title are cut to a message's length. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Message { + pub text: String, + /// The dialog's title, when the script gave one. + pub title: Option, + pub icon: MessageIcon, + pub buttons: MessageButtons, +} + +impl Message { + /// A message with only text: no title, the default icon and an OK button. + pub fn plain(text: impl Into) -> Message { + Message { text: text.into(), ..Default::default() } + } + + /// Bytes of text the message holds, charged to a run's budget. + pub(crate) fn bytes(&self) -> usize { + self.text.len().saturating_add(self.title.as_ref().map_or(0, String::len)) + } +} + +/// Something a script asked the viewer to do. +#[derive(Clone, Debug, PartialEq)] +pub enum Request { + /// `this.resetForm([names])` (empty: every field). + Reset(Vec), + Print, + /// `this.pageNum = n` (0-based). + GoToPage(usize), + LaunchUrl(String), + /// `this.submitForm(url)`: never sent on PdfCraft's own. + Submit(String), + /// `field.setFocus()`. + Focus(String), + Beep, + /// `app.execMenuItem("SaveAs")` (XFA forms' Save buttons). + SaveAs, + /// `app.execMenuItem("ShowHide…")`: show the side panel, or hide it when it is showing. + ShowPanel(Panel), +} + +/// A side panel a script can show or hide (`app.execMenuItem("ShowHideFileAttachment")`, …). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Panel { + /// The document's bookmarks (outline). + Bookmarks, + /// Page thumbnails. + Pages, + /// The files attached to the document. + Attachments, + /// The document's signatures and their validity. + Signatures, + /// The document's layers (optional content). + Layers, +} + +impl Panel { + /// The panel's name in automation output, fixed whatever the variant is called. + pub fn id(self) -> &'static str { + match self { + Panel::Bookmarks => "bookmarks", + Panel::Pages => "pages", + Panel::Attachments => "attachments", + Panel::Signatures => "signatures", + Panel::Layers => "layers", + } + } +} + +/// What running a script did. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Outcome { + /// `event.rc`: `false` rejects the keystroke or value. + pub rc: bool, + /// `event.value` after the script. + pub value: String, + pub change: String, + /// Fields whose value or properties the script changed, as they are now. + pub changed: Vec, + /// The messages the script showed, in order. + pub alerts: Vec, + pub console: Vec, + pub requests: Vec, + /// The script's error (syntax error, exception, or a limit reached), if it failed. + pub error: Option, + /// Errors in the document-level scripts that ran first. Each is a script of its own: one + /// that throws stops where it failed, and the others and the script itself still run (#3940). + pub doc_errors: Vec, + /// The script's completion value (what the console prints), unless undefined. + pub result: Option, +} + +/// Bounds that keep a script from hanging the viewer. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Limits { + pub loop_iterations: u64, + pub recursion: usize, +} + +impl Default for Limits { + fn default() -> Self { + Limits { loop_iterations: 1_000_000, recursion: 256 } + } +} diff --git a/crates/js/src/run.rs b/crates/js/src/run.rs new file mode 100644 index 0000000000..07c0e984bb --- /dev/null +++ b/crates/js/src/run.rs @@ -0,0 +1,256 @@ +use super::globals::{event_object, install}; +use super::host::{Host, Shared, text}; +use super::model::{DocInfo, Event, FieldState, Limits, Outcome}; +use super::{Arc, Attribute, BTreeSet, Context, HostHooks, JsNativeError, JsResult, JsString, Realm, RefCell, Source, js_string}; + +/// Longest script, in bytes. +pub(crate) const MAX_SCRIPT_BYTES: usize = 256 * 1024; + +/// Largest width or precision accepted by `util.printf`. +pub(crate) const MAX_PRINTF_WIDTH_PRECISION: usize = 4096; + +/// Longest `util.printf` output, in bytes. +pub(crate) const MAX_PRINTF_OUTPUT_BYTES: usize = 1 << 20; + +/// Longest array read item by item (`resetForm`, a field's `value` or colours). A sparse array's +/// length costs the script nothing but the host one string per item; real lists are tiny. +pub(crate) const MAX_ARRAY_LENGTH: u64 = 1 << 20; + +/// Deepest nesting of `(`, `[` and `{`. +const MAX_BRACKET_DEPTH: usize = 64; + +/// Longest run of prefix operators (`!`, `~`, `+`, `-`). +const MAX_PREFIX_RUN: usize = 64; + +/// Most `?` (conditional expressions nest without brackets). +const MAX_CONDITIONALS: usize = 4096; + +/// The script thread's stack. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) const SCRIPT_STACK: usize = 1 << 30; + +/// Why `source` is refused, if it is: too long, or nested deeper than any form needs. Strings +/// and comments are skipped; brackets in regular expressions and templates count, which only +/// errs on the safe side. +pub(crate) fn refuse(source: &str) -> Option { + if source.len() > MAX_SCRIPT_BYTES { + return Some(format!("the script is too long ({} KiB; the limit is {} KiB)", source.len() / 1024, MAX_SCRIPT_BYTES / 1024)); + } + let too_deep = || Some("the script is nested too deeply to run safely".to_string()); + let b = source.as_bytes(); + let (mut depth, mut prefix, mut conditionals) = (0usize, 0usize, 0usize); + let mut i = 0; + while let Some(&c) = b.get(i) { + i += 1; + match c { + b'\'' | b'"' => { + // To the closing quote (or the end of the line, where an unclosed string ends). + while let Some(&d) = b.get(i) { + i += 1; + if d == b'\\' { + i += 1; + } else if d == c || d == b'\n' { + break; + } + } + prefix = 0; + } + b'/' if b.get(i) == Some(&b'/') => { + while b.get(i).is_some_and(|d| *d != b'\n') { + i += 1; + } + } + b'/' if b.get(i) == Some(&b'*') => { + i += 1; + while i < b.len() && !b[i..].starts_with(b"*/") { + i += 1; + } + i += 2; + } + b'(' | b'[' | b'{' => { + depth += 1; + if depth > MAX_BRACKET_DEPTH { + return too_deep(); + } + prefix = 0; + } + b')' | b']' | b'}' => { + depth = depth.saturating_sub(1); + prefix = 0; + } + b'!' | b'~' | b'+' | b'-' => { + prefix += 1; + if prefix > MAX_PREFIX_RUN { + return too_deep(); + } + } + b'?' => { + conditionals += 1; + if conditionals > MAX_CONDITIONALS { + return too_deep(); + } + prefix = 0; + } + c if c.is_ascii_whitespace() => {} + _ => prefix = 0, + } + } + None +} + +/// Run `script` for `event`, after the document-level scripts (`doc_scripts`, which define the +/// functions field scripts call), with the form's `fields`. +pub fn run(script: &str, event: &Event, doc: &DocInfo, fields: &[FieldState], doc_scripts: &[String], limits: Limits) -> Outcome { + run_shared(script, event, doc, Arc::new(fields.to_vec()), doc_scripts, limits) +} + +/// Like [`run`], sharing an immutable initial field snapshot. Scripts still get a fresh +/// context; the first field write copies the snapshot, so neither the caller nor another +/// script can observe uncommitted writes. Read-only calculations avoid copying the whole form. +pub fn run_shared(script: &str, event: &Event, doc: &DocInfo, fields: Arc>, doc_scripts: &[String], limits: Limits) -> Outcome { + let failed = |why: String| Outcome { rc: true, value: event.value.clone(), change: event.change.clone(), error: Some(why), ..Default::default() }; + if let Some(why) = refuse(script) { + return failed(why); + } + // A document script too long or too deeply nested to run safely is skipped and reported, + // as one that throws is; the others and the script still run (#3940). + let mut skipped = Vec::new(); + let kept: Vec<(usize, &str)> = doc_scripts + .iter() + .enumerate() + .filter_map(|(i, d)| match refuse(d) { + Some(why) => { + skipped.push((i, why)); + None + } + None => Some((i, d.as_str())), + }) + .collect(); + let (kept, skipped) = (&kept, &skipped); + #[cfg(not(target_arch = "wasm32"))] + { + std::thread::scope(|scope| { + let spawned = std::thread::Builder::new() + .name("pdfcraft-js".into()) + .stack_size(SCRIPT_STACK) + .spawn_scoped(scope, || run_here(script, event, doc, fields, kept, skipped, limits)); + match spawned { + Ok(thread) => thread.join().unwrap_or_else(|_| failed("the script stopped with an internal error".into())), + Err(e) => failed(format!("the script engine could not start: {e}")), + } + }) + } + #[cfg(target_arch = "wasm32")] + run_here(script, event, doc, fields, kept, skipped, limits) +} + +/// Host hooks that refuse to compile code from strings (`eval`, `Function` and its generator +/// and async kin). Such code is built at run time, so it never passed [`refuse`], and boa's +/// recursive parser would overflow the stack on a deeply nested one. +struct NoCodeFromStrings; + +impl HostHooks for NoCodeFromStrings { + fn ensure_can_compile_strings(&self, _: Realm, _: &[JsString], _: &JsString, _: bool, _: &mut Context) -> JsResult<()> { + Err(JsNativeError::typ().with_message("scripts can't compile code from strings (eval, Function)").into()) + } +} + +/// Host hooks that hold code compiled from strings to the checks a script passes ([`refuse`]), +/// for runners that start the script itself through `eval`. +pub(crate) struct CheckedCodeFromStrings; + +impl HostHooks for CheckedCodeFromStrings { + fn ensure_can_compile_strings(&self, _: Realm, parameters: &[JsString], body: &JsString, _: bool, _: &mut Context) -> JsResult<()> { + let units = parameters.iter().chain(std::iter::once(body)).fold(0usize, |n, s| n.saturating_add(s.len())); + if units > MAX_SCRIPT_BYTES { + return Err(JsNativeError::range().with_message("the code is too long to compile").into()); + } + // As one text: brackets left open in one parameter carry into the next. + let mut source = parameters.iter().map(JsString::to_std_string_lossy).collect::>().join(","); + source.push('\n'); + source.push_str(&body.to_std_string_lossy()); + match refuse(&source) { + Some(why) => Err(JsNativeError::range().with_message(why).into()), + None => Ok(()), + } + } +} + +/// How [`Outcome::doc_errors`] names a failing document script: by its 1-based place. +fn doc_error(index: usize, error: &dyn std::fmt::Display) -> String { + format!("document script {}: {error}", index.saturating_add(1)) +} + +/// `doc_scripts` are the document scripts to run with their places among all of them; +/// `skipped` the ones refused before running, with the reason. +fn run_here( + script: &str, + event: &Event, + doc: &DocInfo, + fields: Arc>, + doc_scripts: &[(usize, &str)], + skipped: &[(usize, String)], + limits: Limits, +) -> Outcome { + let mut ctx = match Context::builder().host_hooks(std::rc::Rc::new(NoCodeFromStrings)).build() { + Ok(ctx) => ctx, + Err(e) => { + let error = Some(format!("the script engine could not start: {e}")); + return Outcome { rc: true, value: event.value.clone(), change: event.change.clone(), error, ..Default::default() }; + } + }; + ctx.runtime_limits_mut().set_loop_iteration_limit(limits.loop_iterations); + ctx.runtime_limits_mut().set_recursion_limit(limits.recursion); + ctx.insert_data(RefCell::new(Host { + fields, + changed: BTreeSet::new(), + alerts: Vec::new(), + console: Vec::new(), + requests: Vec::new(), + doc: doc.clone(), + kept: 0, + dropped: 0, + })); + let mut out = Outcome { rc: true, value: event.value.clone(), change: event.change.clone(), ..Default::default() }; + let mut doc_errors: Vec<(usize, String)> = skipped.iter().map(|(i, why)| (*i, doc_error(*i, why))).collect(); + let result = (|| -> JsResult<()> { + install(&mut ctx)?; + let ev = event_object(&mut ctx, event); + ctx.register_global_property(js_string!("event"), ev.clone(), Attribute::all())?; + // One document script that throws (calling a method PdfCraft lacks, say) must not keep + // every field script in the form from running: what it defined before the error, and + // the other scripts, stay available (#3940). + for &(i, d) in doc_scripts { + if let Err(e) = ctx.eval(Source::from_bytes(d)) { + doc_errors.push((i, doc_error(i, &e))); + } + } + let r = ctx.eval(Source::from_bytes(script))?; + if !r.is_undefined() { + out.result = Some(text(&r, &mut ctx)?); + } + let rc = ev.get(js_string!("rc"), &mut ctx)?; + out.rc = rc.to_boolean(); + let v = ev.get(js_string!("value"), &mut ctx)?; + out.value = text(&v, &mut ctx)?; + let c = ev.get(js_string!("change"), &mut ctx)?; + out.change = text(&c, &mut ctx)?; + Ok(()) + })(); + if let Err(e) = result { + out.error = Some(e.to_string()); + } + doc_errors.sort_by_key(|(i, _)| *i); + out.doc_errors = doc_errors.into_iter().map(|(_, e)| e).collect(); + if let Some(h) = ctx.remove_data::() { + let h = h.into_inner(); + out.changed = h.changed.iter().map(|i| h.fields[*i].clone()).collect(); + out.alerts = h.alerts; + out.console = h.console; + out.requests = h.requests; + if h.dropped > 0 { + out.console.push(format!("the script's messages, console lines and requests passed their limits; {} more were left out", h.dropped)); + } + } + out +} diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index 9e96dcd2e3..b61649b729 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -37,11 +37,12 @@ fn numbers_with_leading_zeros_stay_text() { assert_eq!(o.value, "string:number"); } +/// A validate script refuses a value with `event.rc = false` and says why in a message. #[test] fn validate_scripts_reject_with_rc_and_alert() { let o = go("if (event.value > 10) { app.alert('Too many'); event.rc = false; }", &Event::field("Validate", "qty", "11")); assert!(!o.rc); - assert_eq!(o.alerts, ["Too many"]); + assert_eq!(o.alerts, [Message::plain("Too many")]); let o = go("if (event.value > 10) { event.rc = false; }", &Event::field("Validate", "qty", "3")); assert!(o.rc); } @@ -251,9 +252,11 @@ fn kept_bytes(o: &Outcome) -> usize { Request::Submit(u) | Request::LaunchUrl(u) | Request::Focus(u) => u.len(), _ => 0, }; - o.console.iter().chain(&o.alerts).map(String::len).sum::() + o.requests.iter().map(req).sum::() + o.console.iter().map(String::len).sum::() + o.alerts.iter().map(Message::bytes).sum::() + o.requests.iter().map(req).sum::() } +/// Console lines, messages and requests are capped in number and in bytes, and what is left out is +/// noted. #[test] fn exec_menu_item_requests_are_capped_like_other_requests() { let o = go("for (var i = 0; i < 12000; i++) app.execMenuItem('Print');", &Event::field("Mouse Up", "agree", "")); @@ -275,11 +278,99 @@ fn console_alerts_and_requests_are_capped_in_count_and_bytes() { assert!(o.console.len() <= crate::xfa::MAX_CONSOLE + 1, "{} console lines", o.console.len()); assert!(o.alerts.len() <= crate::xfa::MAX_ALERTS, "{} alerts", o.alerts.len()); assert!(o.requests.len() <= crate::xfa::MAX_EFFECTS, "{} requests", o.requests.len()); - assert!(o.console.iter().chain(&o.alerts).all(|m| m.chars().count() <= 4_097)); + assert!(o.console.iter().chain(o.alerts.iter().map(|m| &m.text)).all(|m| m.chars().count() <= 4_097)); assert!(kept_bytes(&o) <= crate::xfa::MAX_PAYLOAD, "{} bytes kept", kept_bytes(&o)); assert!(o.console.last().is_some_and(|l| l.contains("left out")), "{:?}", o.console.last()); } +/// The message `app.alert(args)` shows, from a button's Mouse Up script. +fn alerted(args: &str) -> Message { + let o = go(&format!("app.alert({args});"), &Event::field("Mouse Up", "agree", "")); + assert_eq!(o.error, None, "{args}"); + assert_eq!(o.alerts.len(), 1, "{args}"); + o.alerts.into_iter().next().unwrap_or_default() +} + +/// `app.alert(cMsg, nIcon, nType, cTitle)` keeps every icon and button set the JavaScript for +/// Acrobat API Reference numbers, and the title. +#[test] +fn app_alert_keeps_its_title_icon_and_buttons() { + let icons = [MessageIcon::Error, MessageIcon::Warning, MessageIcon::Question, MessageIcon::Status]; + let buttons = [MessageButtons::Ok, MessageButtons::OkCancel, MessageButtons::YesNo, MessageButtons::YesNoCancel]; + for (i, icon) in icons.iter().enumerate() { + for (b, kind) in buttons.iter().enumerate() { + let m = alerted(&format!("'Sure?', {i}, {b}, 'Order form'")); + assert_eq!(m, Message { text: "Sure?".into(), title: Some("Order form".into()), icon: *icon, buttons: *kind }, "{i} {b}"); + } + } + // Only the message: no title, the Error icon and the OK button, the defaults. + assert_eq!(alerted("'Saved'"), Message::plain("Saved")); + assert_eq!(alerted("'Saved', 3"), Message { icon: MessageIcon::Status, ..Message::plain("Saved") }); +} + +/// The single-object form names the same parameters, in any order, and reads nothing else. +#[test] +fn app_alert_reads_the_object_form() { + let m = alerted("{ cTitle: 'Order form', nType: 2, cMsg: 'Clear the rows?', nIcon: 2, bBeep: true }"); + assert_eq!( + m, + Message { text: "Clear the rows?".into(), title: Some("Order form".into()), icon: MessageIcon::Question, buttons: MessageButtons::YesNo } + ); + assert_eq!(alerted("{ cMsg: 'Done' }"), Message::plain("Done")); +} + +/// An object without `cMsg` isn't the object form: it is the message itself, as text, so a +/// String object or a caught error shows what it says; the same for `app.launchURL` and `cURL`. +#[test] +fn objects_that_name_no_parameter_are_the_message_itself() { + assert_eq!(alerted("new String('Saved')"), Message::plain("Saved")); + let o = go("try { throw new Error('No such field'); } catch (e) { app.alert(e); }", &Event::field("Mouse Up", "agree", "")); + assert_eq!(o.alerts.first().map(|m| m.text.as_str()), Some("Error: No such field"), "{o:?}"); + let o = go("app.launchURL(new String('https://example.com/form'));", &Event::field("Mouse Up", "agree", "")); + assert_eq!(o.requests, vec![Request::LaunchUrl("https://example.com/form".into())], "{o:?}"); +} + +/// An icon or button number that isn't one of the four (fractional, negative, too large, not a +/// number, or a value that can't be converted) is the default, and the message still shows; +/// a number given as text counts. +#[test] +fn app_alert_falls_back_on_bad_icon_and_button_numbers() { + for bad in ["4", "-1", "1.5", "NaN", "Infinity", "'x'", "{}", "Symbol('s')", "null", "undefined"] { + let m = alerted(&format!("'Hi', {bad}, {bad}, ''")); + assert_eq!(m, Message::plain("Hi"), "{bad}"); + } + assert_eq!(alerted("'Hi', '2', '3'"), Message { icon: MessageIcon::Question, buttons: MessageButtons::YesNoCancel, ..Message::plain("Hi") }); + // Every call still answers OK (1) until answers are asked for (#4291). + let o = go("event.value = String(app.alert('Delete?', 2, 2));", &Event::field("Mouse Up", "agree", "")); + assert_eq!(o.value, "1"); +} + +/// A title is cut to a message's length like the text, and both are charged to the run's byte +/// budget: titles can't carry more text out than messages can. +#[test] +fn app_alert_titles_are_clipped_and_charged() { + use crate::host::Host; + let m = alerted("'Hi', 0, 0, new Array(100000).join('t')"); + assert!(m.title.as_deref().is_some_and(|t| t.chars().count() <= 4_097), "{:?}", m.title.as_ref().map(String::len)); + // The title's bytes count against the budget with the text's, and a message the budget + // has no room for is left out and counted. + let mut h = Host { + fields: Arc::new(Vec::new()), + changed: BTreeSet::new(), + alerts: Vec::new(), + console: Vec::new(), + requests: Vec::new(), + doc: doc(), + kept: 0, + dropped: 0, + }; + h.alert(Message { title: Some("Order form".into()), ..Message::plain("Saved") }); + assert_eq!(h.kept, "Saved".len() + "Order form".len()); + h.kept = crate::xfa::MAX_PAYLOAD - "Saved".len(); + h.alert(Message { title: Some("T".into()), ..Message::plain("Saved") }); + assert_eq!((h.alerts.len(), h.dropped), (1, 1), "a title past the budget leaves its message out"); +} + #[test] fn printd_reads_a_long_literal_run_in_linear_work() { crate::PRINTD_SCANNED.store(0, std::sync::atomic::Ordering::Relaxed); @@ -435,8 +526,8 @@ fn scripts_cannot_compile_code_from_strings() { } mod xfa_model { - use crate::Limits; use crate::xfa::*; + use crate::{Limits, Message, MessageButtons, MessageIcon}; pub(super) fn field(name: &str, som: &str, value: &str, numeric: bool) -> XfaNode { XfaNode { @@ -670,6 +761,8 @@ mod xfa_model { assert_eq!(o.result.as_deref(), Some("4/1")); } + /// `xfa.host`, `xfa.layout`, `xfa.event` and `app` calls become effects, and messages keep + /// their details. #[test] fn host_layout_event_and_app_calls_become_effects() { let o = go( @@ -681,7 +774,12 @@ mod xfa_model { assert_eq!( o.effects, vec![ - XfaEffect::MessageBox("Hi".into()), + XfaEffect::MessageBox(Message { + text: "Hi".into(), + title: Some("T".into()), + icon: MessageIcon::Status, + buttons: MessageButtons::OkCancel + }), XfaEffect::ResetData(vec!["qty".into(), "price".into()]), XfaEffect::Print, XfaEffect::SaveAs, @@ -726,6 +824,7 @@ mod xfa_model { assert_eq!(o.result.as_deref(), Some("1002")); } + /// A value set again replaces the earlier one, and hostile loops end at the caps. #[test] fn values_set_again_merge_and_hostile_loops_are_capped() { // A calculate that sets its own value in a loop leaves one effect: the last value. @@ -747,7 +846,56 @@ mod xfa_model { assert!(o.notes.iter().any(|n| n.contains("changes")), "{:?}", o.notes); // Long messages are cut. let o = go("xfa.host.messageBox(new Array(100000).join('x'));", "form1[0].page1[0]", "click"); - assert!(matches!(&o.effects[0], XfaEffect::MessageBox(m) if m.chars().count() <= 4_097)); + assert!(matches!(&o.effects[0], XfaEffect::MessageBox(m) if m.text.chars().count() <= 4_097)); + } + + /// The message box `script` showed from a button's click script. + fn boxed(script: &str) -> Message { + let o = go(script, "form1[0].page1[0].go[0]", "click"); + assert_eq!(o.error, None, "{script}"); + match o.effects.as_slice() { + [XfaEffect::MessageBox(m)] => m.clone(), + other => panic!("{script}: {other:?}"), + } + } + + /// `xfa.host.messageBox(message, title, icon, buttons)` keeps every icon and button set the + /// XFA specification numbers, and the title. + #[test] + fn message_box_keeps_its_title_icon_and_buttons() { + let icons = [MessageIcon::Error, MessageIcon::Warning, MessageIcon::Question, MessageIcon::Status]; + let buttons = [MessageButtons::Ok, MessageButtons::OkCancel, MessageButtons::YesNo, MessageButtons::YesNoCancel]; + for (i, icon) in icons.iter().enumerate() { + for (b, kind) in buttons.iter().enumerate() { + let m = boxed(&format!("xfa.host.messageBox('Sure?', 'Order form', {i}, {b})")); + assert_eq!(m, Message { text: "Sure?".into(), title: Some("Order form".into()), icon: *icon, buttons: *kind }, "{i} {b}"); + } + } + assert_eq!(boxed("xfa.host.messageBox('Saved')"), Message::plain("Saved")); + // A bad number is the default; the message still shows. + assert_eq!(boxed("xfa.host.messageBox('Hi', '', 7, -2)"), Message::plain("Hi")); + assert_eq!(boxed("xfa.host.messageBox('Hi', null, 2.5, 'x')"), Message::plain("Hi")); + let m = boxed("xfa.host.messageBox('Hi', new Array(100000).join('t'))"); + assert!(m.title.as_deref().is_some_and(|t| t.chars().count() <= 4_097)); + // Answers are always OK for now (#4291). + let o = go("this.rawValue = xfa.host.messageBox('Delete?', 'T', 2, 2)", "form1[0].page1[0].qty[0]", "click"); + assert!(o.effects.iter().any(|e| matches!(e, XfaEffect::SetValue { value, .. } if value == "1")), "{:?}", o.effects); + } + + /// `app.alert` in an XFA form's script reads its own order (`cMsg, nIcon, nType, cTitle`) + /// and its object form, not `messageBox`'s. + #[test] + fn app_alert_in_an_xfa_script_reads_app_alerts_arguments() { + let m = boxed("app.alert('Sure?', 1, 3, 'Order form')"); + assert_eq!( + m, + Message { text: "Sure?".into(), title: Some("Order form".into()), icon: MessageIcon::Warning, buttons: MessageButtons::YesNoCancel } + ); + let m = boxed("app.alert({ cMsg: 'Sure?', cTitle: 'Order form', nIcon: 2, nType: 1 })"); + assert_eq!( + m, + Message { text: "Sure?".into(), title: Some("Order form".into()), icon: MessageIcon::Question, buttons: MessageButtons::OkCancel } + ); } /// Past the byte budget, adding or removing a row is left out of the snapshot as well as the @@ -818,6 +966,7 @@ mod xfa_model { assert_eq!(names, 1); } + /// Time the engine takes to start is not taken from the script's own time. #[test] fn a_slow_engine_start_does_not_count_against_the_script() { let ms = std::time::Duration::from_millis; @@ -852,9 +1001,9 @@ mod xfa_model { mod formcalc { use super::xfa_model::form; - use crate::Limits; use crate::formcalc::run_formcalc; use crate::xfa::*; + use crate::{Limits, Message, MessageButtons, MessageIcon}; fn go(script: &str, target: &str, activity: &str) -> XfaOutcome { let ev = XfaEvent { activity: activity.into(), target: target.into(), ..Default::default() }; @@ -877,6 +1026,20 @@ mod formcalc { assert_eq!(calc(r#"Format("9.9", -1.5)"#), "-1.5"); } + /// A calculated amount shows the number it is, not the binary noise past a double's 15 + /// significant digits: 279893.88 is stored as 279893.880000000004656…, which twelve fixed + /// decimals printed as `279893.880000000005`. Small values keep their twelve decimals. + #[test] + fn calculated_numbers_print_at_most_fifteen_significant_digits() { + assert_eq!(calc("1 * 279893.88"), "279893.88"); + assert_eq!(calc("1 * 291774.12"), "291774.12"); + assert_eq!(calc("279893.88 + 291774.12 + 304171.06 + 317107.44 + 273970.85"), "1466917.35"); + assert_eq!(calc("1000000 / 3"), "333333.333333333"); + assert_eq!(calc("1000 / 3"), "333.333333333333"); + assert_eq!(calc("1 / 3"), "0.333333333333"); + assert_eq!(calc("0.1 + 0.2"), "0.3"); + } + /// Assigning one long value to many fields at once stops at the run's byte budget and is /// charged per copy, and a reset list names each field once. #[test] @@ -1017,6 +1180,14 @@ mod formcalc { #[test] fn built_in_functions_cover_the_common_set() { + assert_numeric_and_logical_builtins(); + assert_string_builtins(); + assert_format_and_date_builtins(); + assert_financial_and_unit_builtins(); + assert_builtin_errors(); + } + + fn assert_numeric_and_logical_builtins() { assert_eq!(calc("Abs(-2.5)"), "2.5"); assert_eq!(calc(r#"Decode("a%2Fb", "url")"#), "a/b"); assert_eq!(calc(r#"Decode("a%+fb", "url")"#), "a%+fb"); @@ -1034,6 +1205,9 @@ mod formcalc { assert_eq!(calc(r#"Within(5, 1, 10)"#), "1"); assert_eq!(calc(r#"HasValue("")"#), "0"); assert_eq!(calc(r#"HasValue("x")"#), "1"); + } + + fn assert_string_builtins() { assert_eq!(calc(r#"At("abcd", "c")"#), "3"); assert_eq!(calc(r#"Left("hello", 2)"#), "he"); assert_eq!(calc(r#"Right("hello", 2)"#), "lo"); @@ -1050,6 +1224,11 @@ mod formcalc { assert_eq!(calc(r#"WordNum(42)"#), "Forty-Two"); assert_eq!(calc(r#"WordNum(1.999, 2)"#), "Two Dollars And 00 Cents"); assert_eq!(calc(r#"WordNum(1.01, 2)"#), "One Dollar And 01 Cent"); + assert_eq!(calc(r#"Encode("a b&c", "url")"#), "a%20b%26c"); + assert_eq!(calc(r#"Decode("a%20b", "url")"#), "a b"); + } + + fn assert_format_and_date_builtins() { assert_eq!(calc(r#"Format("zzz,zz9.99", 1234.5)"#), "1,234.50"); assert_eq!(calc(r#"Format("$z,zz9.99", -5)"#), "-$5.00"); assert_eq!(calc(r#"Format("MM/DD/YYYY", "2024-02-29")"#), "02/29/2024"); @@ -1065,6 +1244,9 @@ mod formcalc { assert_eq!(calc(r#"IsoTime2Num("12:30:45.250")"#), "45045250"); assert_eq!(calc(r#"IsoTime2Num("T12:30:45")"#), "45045000"); assert_eq!(calc(r#"Num2Time(3661000)"#), "01:01:01"); + } + + fn assert_financial_and_unit_builtins() { assert_eq!(calc("Round(Pmt(10000, 0.01, 12), 2)"), "888.49"); assert_eq!(calc("Round(FV(100, 0.01, 12), 2)"), "1268.25"); assert_eq!(calc("Round(PV(100, 0.01, 12), 2)"), "1125.51"); @@ -1073,8 +1255,9 @@ mod formcalc { assert_eq!(calc(r#"UnitValue("72points", "in")"#), "1"); assert_eq!(calc(r#"UnitValue("25.4millimeters", "inches")"#), "1"); assert_eq!(calc(r#"UnitValue("1in", "points")"#), "72"); - assert_eq!(calc(r#"Encode("a b&c", "url")"#), "a%20b%26c"); - assert_eq!(calc(r#"Decode("a%20b", "url")"#), "a b"); + } + + fn assert_builtin_errors() { assert!(go(r#"Get("http://example.com")"#, "form1[0].page1[0].total[0]", "calculate").error.unwrap().contains("network")); assert!(go("Nope(1)", "form1[0].page1[0].total[0]", "calculate").error.unwrap().contains("unknown function")); } @@ -1145,8 +1328,16 @@ s"# assert!(o.error.unwrap().contains("endif")); } + /// FormCalc resolves form objects, assigns to them and records the effects. #[test] fn form_objects_resolve_assign_and_record_effects() { + assert_formcalc_reads_fields_and_rows(); + assert_formcalc_reads_runtime_properties(); + assert_formcalc_records_primary_effects(); + assert_formcalc_updates_instances_and_records_requests(); + } + + fn assert_formcalc_reads_fields_and_rows() { // Fields deref to their values; `$` is the current field. assert_eq!(calc("qty * price"), "10"); assert_eq!(calc("$.name"), "total"); @@ -1170,7 +1361,6 @@ s"# assert_eq!(in_row("Sum(row[*].amount)"), "15"); assert_eq!(in_row("row[1].amount"), "5"); assert_eq!(in_row("row[-1].amount"), "10"); - // Past the ends there is no object, so a property of it is an error (as in Acrobat). let past = go("row[+1].amount", "form1[0].page1[0].table[0].row[1].amount[0]", "calculate"); assert!(past.error.unwrap().contains("no object")); assert_eq!(in_row("Exists(row[-5])"), "0"); @@ -1182,12 +1372,18 @@ s"# assert_eq!(calc("table._row.count"), "2"); assert_eq!(calc("Exists(qty) & not Exists(nothing)"), "1"); assert_eq!(calc("HasValue(total)"), "0"); + } + + fn assert_formcalc_reads_runtime_properties() { assert_eq!(calc("$host.numPages"), "3"); assert_eq!(calc("$layout.pageCount()"), "3"); assert_eq!(calc("$event.name"), "calculate"); assert_eq!(calc("xfa.resolveNode(\"page1.price\").rawValue"), "2.5"); assert_eq!(calc("$.border.fill.color.value"), ""); assert!(go("nothing + 1", "form1[0].page1[0].total[0]", "calculate").error.unwrap().contains("unknown name")); + } + + fn assert_formcalc_records_primary_effects() { // Assignment to a field (by name and by path), a property and a variable. let o = go( r#"page1.qty = 6 @@ -1210,12 +1406,14 @@ table._row.count"#, XfaEffect::SetValue { som: "form1[0].page1[0].qty[0]".into(), value: "7".into() }, XfaEffect::SetPresence { som: "form1[0].page1[0].details[0]".into(), presence: "visible".into() }, XfaEffect::SetAccess { som: "form1[0].page1[0].price[0]".into(), access: "readOnly".into() }, - XfaEffect::MessageBox("Total 17.5".into()), + XfaEffect::MessageBox(Message::plain("Total 17.5")), XfaEffect::SetFocus("form1[0].page1[0].qty[0]".into()), XfaEffect::AddInstance { som: "form1[0].page1[0].table[0].row[2]".into() }, ] ); - // Instance managers: remove, setInstances, count assignment, and the floor of one. + } + + fn assert_formcalc_updates_instances_and_records_requests() { let o = go( "table._row.removeInstance(0)\ntable._row.setInstances(1)\ntable._row.count = 0\ntable._row.count", "form1[0].page1[0].go[0]", @@ -1238,7 +1436,7 @@ $host.gotoURL("https://example.org")"#, ); assert_eq!( o.effects, - vec![XfaEffect::ResetData(vec!["qty".into(), "price".into()]), XfaEffect::Print, XfaEffect::LaunchUrl("https://example.org".into())] + vec![XfaEffect::ResetData(vec!["qty".into(), "price".into()]), XfaEffect::Print, XfaEffect::LaunchUrl("https://example.org".into()),] ); } @@ -1277,60 +1475,82 @@ $host.gotoURL("https://example.org")"#, assert_eq!(run(&script).result.as_deref(), Some("2")); } + /// Hostile FormCalc scripts end with an error and bounded output. #[test] fn hostile_formcalc_scripts_end_with_an_error_and_bounded_output() { - let total = "form1[0].page1[0].total[0]"; - let fast = Limits { loop_iterations: 10_000, recursion: 32 }; - let ev = XfaEvent { activity: "calculate".into(), target: total.into(), ..Default::default() }; - let doc = XfaDoc::default(); - let run = |s: &str| run_formcalc(s, &ev, &doc, &form(), fast); - assert!(run("while (1) do endwhile").error.unwrap().contains("looped")); - assert!(run("for i = 1 upto 1000000 do endfor").error.unwrap().contains("looped")); - assert!(run("func f(n) do f(n + 1) endfunc\nf(1)").error.unwrap().contains("nested")); - assert!(run(&format!("{}1{}", "(".repeat(500), ")".repeat(500))).error.is_some()); + assert_hostile_control_flow_is_bounded(); + assert_hostile_numeric_inputs_are_safe(); + assert_hostile_outputs_are_bounded(); + assert_hostile_nesting_is_bounded(); + assert_hostile_deadlines_and_parse_errors(); + assert_hostile_instance_growth_is_bounded(); + assert_hostile_message_count_is_bounded(); + assert_hostile_small_stack_is_safe(); + } + + fn assert_hostile_control_flow_is_bounded() { + assert!(run_hostile_formcalc("while (1) do endwhile").error.unwrap().contains("looped")); + assert!(run_hostile_formcalc("for i = 1 upto 1000000 do endfor").error.unwrap().contains("looped")); + assert!(run_hostile_formcalc("func f(n) do f(n + 1) endfunc\nf(1)").error.unwrap().contains("nested")); + assert!(run_hostile_formcalc(&format!("{}1{}", "(".repeat(500), ")".repeat(500))).error.is_some()); // A 120k-term chain is refused at parse time (its tree would be as deep as it is long). - assert!(run(&format!("1{}", "+1".repeat(120_000))).error.unwrap().contains("too long")); - assert!(run(&format!("$form{}", ".a".repeat(120_000))).error.unwrap().contains("too long")); - assert!(run("nothing.x = 1").error.unwrap().contains("unknown name")); - assert!(run("$form.nothing = 1").error.unwrap().contains("no nothing")); + assert!(run_hostile_formcalc(&format!("1{}", "+1".repeat(120_000))).error.unwrap().contains("too long")); + assert!(run_hostile_formcalc(&format!("$form{}", ".a".repeat(120_000))).error.unwrap().contains("too long")); + assert!(run_hostile_formcalc("nothing.x = 1").error.unwrap().contains("unknown name")); + assert!(run_hostile_formcalc("$form.nothing = 1").error.unwrap().contains("no nothing")); // Values go into fields only: a subform or the form itself is refused, nothing is recorded. - let o = run("details = \"junk\""); + let o = run_hostile_formcalc("details = \"junk\""); assert!(o.error.unwrap().contains("not a field")); assert!(o.effects.is_empty()); - assert!(run("$form = \"junk\"").error.unwrap().contains("not a field")); + assert!(run_hostile_formcalc("$form = \"junk\"").error.unwrap().contains("not a field")); + } + + fn assert_hostile_numeric_inputs_are_safe() { // Hostile numbers into the string functions. - assert_eq!(run(r#"Stuff("abc", 2, 1e300, "X")"#).result.as_deref(), Some("aX")); - assert_eq!(run(r#"Stuff("abc", -1e300, 1, "X")"#).result.as_deref(), Some("Xbc")); - assert_eq!(run(r#"Substr("abc", 1e300, 1e300)"#).result.as_deref(), Some("")); - assert_eq!(run(r#"Left("abc", -1e300)"#).result.as_deref(), Some("")); - assert!(run(r#"Str(1e300, 1e300, 1e300)"#).error.is_none()); - assert!(run(r#"Round(1e300, 1e300)"#).error.is_none()); - assert!(run(r#"Choose(1e300, 1)"#).error.is_none()); + assert_eq!(run_hostile_formcalc(r#"Stuff("abc", 2, 1e300, "X")"#).result.as_deref(), Some("aX")); + assert_eq!(run_hostile_formcalc(r#"Stuff("abc", -1e300, 1, "X")"#).result.as_deref(), Some("Xbc")); + assert_eq!(run_hostile_formcalc(r#"Substr("abc", 1e300, 1e300)"#).result.as_deref(), Some("")); + assert_eq!(run_hostile_formcalc(r#"Left("abc", -1e300)"#).result.as_deref(), Some("")); + assert!(run_hostile_formcalc(r#"Str(1e300, 1e300, 1e300)"#).error.is_none()); + assert!(run_hostile_formcalc(r#"Round(1e300, 1e300)"#).error.is_none()); + assert!(run_hostile_formcalc(r#"Choose(1e300, 1)"#).error.is_none()); + } + + fn assert_hostile_outputs_are_bounded() { // Strings can't grow past the cap through any function, and messages and values are clipped. assert!( - run("var s = Space(1000000)\nfor i = 1 upto 10 do s = Encode(Replace(s, \" \", \"&\"), \"html\") endfor\nLen(s)") + run_hostile_formcalc("var s = Space(1000000)\nfor i = 1 upto 10 do s = Encode(Replace(s, \" \", \"&\"), \"html\") endfor\nLen(s)") .error .unwrap() .contains("too long") ); - assert!(run("Upper(Concat(Space(600000), Space(600000)))").error.unwrap().contains("too long")); - let o = run("$host.messageBox(Space(100000))"); - assert!(matches!(o.effects.as_slice(), [XfaEffect::MessageBox(m)] if m.chars().count() <= 4100), "{:?}", o.effects.len()); - let o = run("qty = Space(100000)"); + assert!(run_hostile_formcalc("Upper(Concat(Space(600000), Space(600000)))").error.unwrap().contains("too long")); + let o = run_hostile_formcalc("$host.messageBox(Space(100000))"); + assert!(matches!(o.effects.as_slice(), [XfaEffect::MessageBox(m)] if m.text.chars().count() <= 4100), "{:?}", o.effects.len()); + let o = run_hostile_formcalc("qty = Space(100000)"); assert!(matches!(o.effects.as_slice(), [XfaEffect::SetValue { value, .. }] if value.chars().count() == 65_536), "{:?}", o.effects.len()); assert!(o.notes.iter().any(|n| n.contains("cut")), "{:?}", o.notes); + } + + fn assert_hostile_nesting_is_bounded() { // A comment of dashes is not deep nesting. - assert_eq!(run(&format!("; {}\n1 + 1", "-".repeat(70))).result.as_deref(), Some("2")); + assert_eq!(run_hostile_formcalc(&format!("; {}\n1 + 1", "-".repeat(70))).result.as_deref(), Some("2")); // Parentheses don't reset the chain count: a tree is never deeper than the cap. let mut nested = "1+1+1+1+1+1+1+1+1+1".to_string(); for _ in 0..30 { nested = format!("({nested}{})", "+1".repeat(40)); } - assert!(run(&nested).error.unwrap().contains("too long")); - assert_eq!(run("(1+2)*(3+4) + (5+6)").result.as_deref(), Some("32")); + assert!(run_hostile_formcalc(&nested).error.unwrap().contains("too long")); + assert_eq!(run_hostile_formcalc("(1+2)*(3+4) + (5+6)").result.as_deref(), Some("32")); // Nested statements inside recursion count against the depth limit. let deep = format!("func f(n) do\n{}return f(n - 1)\n{}endfunc\nf(1000)", "if (n > 0) then\n".repeat(20), "endif\n".repeat(20)); - assert!(run(&deep).error.unwrap().contains("nested")); + assert!(run_hostile_formcalc(&deep).error.unwrap().contains("nested")); + } + + fn assert_hostile_deadlines_and_parse_errors() { + let total = "form1[0].page1[0].total[0]"; + let ev = XfaEvent { activity: "calculate".into(), target: total.into(), ..Default::default() }; + let doc = XfaDoc::default(); // A deadline already passed stops the script at its first look at the clock. let o = crate::formcalc::run_formcalc_at( "for i = 1 upto 100000 do Eval(\"1\") endfor", @@ -1341,29 +1561,40 @@ $host.gotoURL("https://example.org")"#, Some(std::time::Duration::ZERO), ); assert!(o.error.unwrap().contains("too long")); - assert!(run(&format!("{}1{}", "-".repeat(5000), "")).error.is_some()); - assert!(run("var s = \"x\"\nfor i = 1 upto 100 do s = Concat(s, s) endfor\nLen(s)").error.unwrap().contains("too long")); - assert!(run("Space(1e12)").error.is_none()); - assert!(run("Replace(Space(100000), \" \", Space(100000))").error.unwrap().contains("too long")); - assert_eq!(run(r#"Format("YYYY-MM-DD", 1e300)"#).error, None); - assert_eq!(run(r#"Date2Num("99999999999999999999-01-01", "YYYYYYYYYYYYYYYYYYYY-MM-DD")"#).error, None); - assert_eq!(run(r#"Time2Num("99999999999999999999:0:0")"#).error, None); - assert_eq!(run(r#"Num2Date(1e300, "YYYY")"#).result.as_deref(), Some("")); - assert!(run("for i = 1 upto 1000 do nothing endfor").error.unwrap().contains("unknown name")); - assert!(run("\"unterminated").error.unwrap().contains("unterminated")); - assert!(run("1 ) 2").error.is_some()); - assert!(run("Eval(\"Eval(\"\"Eval(\"\"\"\"1\"\"\"\")\"\")\")").error.is_none()); + assert!(run_hostile_formcalc(&format!("{}1{}", "-".repeat(5000), "")).error.is_some()); + assert!(run_hostile_formcalc("var s = \"x\"\nfor i = 1 upto 100 do s = Concat(s, s) endfor\nLen(s)").error.unwrap().contains("too long")); + assert!(run_hostile_formcalc("Space(1e12)").error.is_none()); + assert!(run_hostile_formcalc("Replace(Space(100000), \" \", Space(100000))").error.unwrap().contains("too long")); + assert_eq!(run_hostile_formcalc(r#"Format("YYYY-MM-DD", 1e300)"#).error, None); + assert_eq!(run_hostile_formcalc(r#"Date2Num("99999999999999999999-01-01", "YYYYYYYYYYYYYYYYYYYY-MM-DD")"#).error, None); + assert_eq!(run_hostile_formcalc(r#"Time2Num("99999999999999999999:0:0")"#).error, None); + assert_eq!(run_hostile_formcalc(r#"Num2Date(1e300, "YYYY")"#).result.as_deref(), Some("")); + assert!(run_hostile_formcalc("for i = 1 upto 1000 do nothing endfor").error.unwrap().contains("unknown name")); + assert!(run_hostile_formcalc("\"unterminated").error.unwrap().contains("unterminated")); + assert!(run_hostile_formcalc("1 ) 2").error.is_some()); + assert!(run_hostile_formcalc("Eval(\"Eval(\"\"Eval(\"\"\"\"1\"\"\"\")\"\")\")").error.is_none()); + } + + fn assert_hostile_instance_growth_is_bounded() { // Adding rows forever stops at the instance cap with the effects it managed. - let o = run("for i = 1 upto 100000 do table._row.addInstance(1) endfor\ntable._row.count"); + let o = run_hostile_formcalc("for i = 1 upto 100000 do table._row.addInstance(1) endfor\ntable._row.count"); assert!(o.error.unwrap().contains("looped")); assert!(o.effects.iter().filter(|e| matches!(e, XfaEffect::AddInstance { .. })).count() <= 1000, "{}", o.effects.len()); - let o = run("for i = 1 upto 5000 do table._row.addInstance(1) endfor\ntable._row.count"); + let o = run_hostile_formcalc("for i = 1 upto 5000 do table._row.addInstance(1) endfor\ntable._row.count"); assert_eq!(o.error, None); assert_eq!(o.result.as_deref(), Some("1002")); + } + + fn assert_hostile_message_count_is_bounded() { // Message boxes past the cap are dropped and noted. - let o = run("for i = 1 upto 500 do $host.messageBox(i) endfor"); + let o = run_hostile_formcalc("for i = 1 upto 500 do $host.messageBox(i) endfor"); assert_eq!(o.effects.iter().filter(|e| matches!(e, XfaEffect::MessageBox(_))).count(), 100); assert!(!o.notes.is_empty()); + } + + fn assert_hostile_small_stack_is_safe() { + let ev = XfaEvent { activity: "calculate".into(), target: "form1[0].page1[0].total[0]".into(), ..Default::default() }; + let doc = XfaDoc::default(); // On a small stack (the wasm build's), the limits stop the script before the stack does. let small = std::thread::Builder::new() .stack_size(1 << 20) @@ -1400,6 +1631,34 @@ $host.gotoURL("https://example.org")"#, ); assert!(o.abandoned || o.error.is_some(), "{o:?}"); } + + /// FormCalc's `$host.messageBox(message, title, icon, buttons)` keeps every icon and button + /// set and the title, as the JavaScript one does. + #[test] + fn formcalc_message_box_keeps_its_title_icon_and_buttons() { + let fc = |s: &str| go(s, "form1[0].page1[0].go[0]", "click"); + let icons = [MessageIcon::Error, MessageIcon::Warning, MessageIcon::Question, MessageIcon::Status]; + let buttons = [MessageButtons::Ok, MessageButtons::OkCancel, MessageButtons::YesNo, MessageButtons::YesNoCancel]; + for (i, icon) in icons.iter().enumerate() { + for (b, kind) in buttons.iter().enumerate() { + let o = fc(&format!("$host.messageBox(\"Sure?\", \"Order form\", {i}, {b})")); + let want = Message { text: "Sure?".into(), title: Some("Order form".into()), icon: *icon, buttons: *kind }; + assert_eq!(o.effects, vec![XfaEffect::MessageBox(want)], "{i} {b}"); + } + } + // Missing or bad numbers are the defaults; a long title is cut like the message. + assert_eq!(fc("$host.messageBox(\"Hi\")").effects, vec![XfaEffect::MessageBox(Message::plain("Hi"))]); + assert_eq!(fc("$host.messageBox(\"Hi\", \"\", 9, 1.5)").effects, vec![XfaEffect::MessageBox(Message::plain("Hi"))]); + let o = fc("$host.messageBox(\"Hi\", Space(100000))"); + assert!(matches!(o.effects.as_slice(), [XfaEffect::MessageBox(m)] if m.title.as_deref().is_some_and(|t| t.chars().count() <= 4_097))); + } + + fn run_hostile_formcalc(script: &str) -> XfaOutcome { + let ev = XfaEvent { activity: "calculate".into(), target: "form1[0].page1[0].total[0]".into(), ..Default::default() }; + let doc = XfaDoc::default(); + let fast = Limits { loop_iterations: 10_000, recursion: 32 }; + run_formcalc(script, &ev, &doc, &form(), fast) + } } mod session { @@ -1811,5 +2070,5 @@ fn a_plain_form_sees_the_xfa_version_but_not_installed() { let o = go(XFA_CHECK, &Event::doc("Open")); assert_eq!(o.error, None); assert_eq!(o.value, "undefined number 3.5"); - assert_eq!(o.alerts, ["old viewer"]); + assert_eq!(o.alerts, [Message::plain("old viewer")]); } diff --git a/crates/js/src/xfa.rs b/crates/js/src/xfa.rs index 42b1688ed5..eb275d1822 100644 --- a/crates/js/src/xfa.rs +++ b/crates/js/src/xfa.rs @@ -15,1641 +15,22 @@ use boa_engine::object::builtins::{JsArray, JsProxy}; use boa_engine::property::Attribute; use boa_engine::{Context, JsObject, JsResult, JsString, JsValue, NativeFunction, Source, js_string}; -use crate::{Limits, arg, as_number, error, function, platform, printd, printf, printx, refuse, s, scand, text}; - -/// What kind of template object a node is. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum XfaKind { - /// `xfa.form` itself. - Form, - Subform, - Field, - ExclGroup, - Draw, - Area, -} - -impl XfaKind { - pub(crate) fn class_name(self) -> &'static str { - match self { - XfaKind::Form => "form", - XfaKind::Subform => "subform", - XfaKind::Field => "field", - XfaKind::ExclGroup => "exclGroup", - XfaKind::Draw => "draw", - XfaKind::Area => "area", - } - } -} - -/// One object of the form, as the caller snapshots it. -#[derive(Clone, Debug, Default, PartialEq)] -pub struct XfaNode { - pub name: String, - /// The SOM expression naming this instance (`form[0].head[0].familyName[0]`). - pub som: String, - pub kind: Option, - /// Fields: the raw value (ISO dates, on values for buttons); empty when null. - pub value: String, - /// Numeric fields give numbers to scripts. - pub numeric: bool, - /// `visible`, `invisible`, `hidden` or `inactive`. - pub presence: String, - /// `open`, `readOnly`, `protected` or `nonInteractive`. - pub access: String, - /// Subforms: may have several instances (`occur max` other than 1). - pub repeatable: bool, - pub occur_min: usize, - pub occur_max: Option, - /// This instance's index among its same-named siblings. - pub index: usize, - pub children: Vec, - /// Fields: the value shown in the field's display picture, what `formattedValue` reads; - /// `None` when the picture doesn't change it (the raw value is read then). - pub formatted: Option, -} - -/// The event a script runs for. -#[derive(Clone, Debug, Default, PartialEq)] -pub struct XfaEvent { - /// `click`, `calculate`, `validate`, `initialize`, `change`, `exit`, … - pub activity: String, - /// The SOM of `this`. - pub target: String, - pub new_text: String, - pub prev_text: String, -} - -/// About the document. -#[derive(Clone, Debug, Default, PartialEq)] -pub struct XfaDoc { - pub file_name: String, - /// The current page, 0-based. - pub page: usize, - pub page_count: usize, -} - -/// Something a script changed or asked for. -#[derive(Clone, Debug, PartialEq)] -pub enum XfaEffect { - SetValue { - som: String, - value: String, - }, - SetPresence { - som: String, - presence: String, - }, - SetAccess { - som: String, - access: String, - }, - /// A new instance of a repeating subform, named by its own SOM (`…row[3]`). - AddInstance { - som: String, - }, - /// The instance at this SOM is removed. - RemoveInstance { - som: String, - }, - MessageBox(String), - /// `xfa.host.resetData()`: the named fields, or every field when empty. - ResetData(Vec), - /// `app.execMenuItem("ShowHide…")`: show or hide a side panel. - ShowPanel(crate::Panel), - Print, - SaveAs, - LaunchUrl(String), - SetFocus(String), - Beep, - /// `xfa.form.recalculate(true)`. - Recalculate, - /// `xfa.form.remerge()`, `xfa.layout.relayout()`. - Relayout, -} - -/// What running a script did. -#[derive(Clone, Debug, Default, PartialEq)] -pub struct XfaOutcome { - pub effects: Vec, - pub console: Vec, - /// The script's error, if it failed. - pub error: Option, - /// The script's completion value (a calculate script's result; a validate script's verdict). - pub result: Option, - /// The completion value as a boolean, when it was one. - pub result_bool: Option, - /// Limits the script ran into (too many effects, message boxes or console lines): what - /// was left out, for the caller to report. - pub notes: Vec, - /// The script ran past its time limit and was left running on its own thread (the engine - /// can't interrupt it): the caller should run no more of this form's scripts. - pub abandoned: bool, -} - -/// Most nodes a snapshot may hold. -const MAX_NODES: usize = 200_000; -/// Most instances a script may add in one run. -pub(crate) const MAX_ADDED: usize = 1_000; -/// Deepest tree followed. -const MAX_DEPTH: usize = 64; -/// Most effects one run may produce (after values set again on the same object are merged). -pub const MAX_EFFECTS: usize = 10_000; -/// Most message boxes one run may show. -pub const MAX_ALERTS: usize = 100; -/// Most console lines one run may print. -pub const MAX_CONSOLE: usize = 1_000; -/// Longest message or console line kept, in characters. -pub(crate) const MAX_LINE: usize = 4_096; -/// Most bytes of text one run may hand back or write: messages, console lines, URLs, names -/// and the values it sets. Past it, more are left out and noted. -pub(crate) const MAX_PAYLOAD: usize = 32 << 20; - -/// Longest value a script may put in a field (characters); longer ones are cut and noted. -const MAX_VALUE: usize = 65_536; - -/// `m` cut to [`MAX_LINE`] characters. -pub(crate) fn clip(m: String) -> String { - match m.char_indices().nth(MAX_LINE) { - Some((at, _)) => format!("{}…", m.get(..at).unwrap_or_default()), - None => m, - } -} - -/// `v` cut to [`MAX_VALUE`] characters, and whether it was cut. -pub(crate) fn clip_value(v: String) -> (String, bool) { - match v.char_indices().nth(MAX_VALUE) { - Some((at, _)) => (v.get(..at).unwrap_or_default().to_string(), true), - None => (v, false), - } -} - -/// The field names of a `resetData` list (comma-separated), each cut to [`MAX_LINE`] and -/// named once, at most as many as a snapshot holds nodes. -pub(crate) fn reset_names(list: &str) -> Vec { - let mut seen = HashSet::new(); - let mut out = Vec::new(); - for name in list.split(',').map(str::trim).filter(|s| !s.is_empty()) { - if out.len() >= MAX_NODES { - break; - } - let name = clip(name.to_string()); - if seen.insert(name.clone()) { - out.push(name); - } - } - out -} - -/// The (property, SOM) an effect setting a property of an object is merged by, see -/// [`XHost::set_at`]. -fn effect_key(e: &XfaEffect) -> Option<(u8, String)> { - match e { - XfaEffect::SetValue { som, .. } => Some((0, som.clone())), - XfaEffect::SetPresence { som, .. } => Some((1, som.clone())), - XfaEffect::SetAccess { som, .. } => Some((2, som.clone())), - _ => None, - } -} - -/// Bytes of text `e` holds. -fn payload(e: &XfaEffect) -> usize { - match e { - XfaEffect::SetValue { som, value: s } | XfaEffect::SetPresence { som, presence: s } | XfaEffect::SetAccess { som, access: s } => { - som.len().saturating_add(s.len()) - } - XfaEffect::AddInstance { som } | XfaEffect::RemoveInstance { som } => som.len(), - XfaEffect::MessageBox(s) | XfaEffect::LaunchUrl(s) | XfaEffect::SetFocus(s) => s.len(), - XfaEffect::ResetData(names) => names.iter().fold(0usize, |n, s| n.saturating_add(s.len()).saturating_add(std::mem::size_of::())), - XfaEffect::Print | XfaEffect::SaveAs | XfaEffect::Beep | XfaEffect::Recalculate | XfaEffect::Relayout | XfaEffect::ShowPanel(_) => 0, - } -} - -/// A script's completion value (what a calculate script puts in its field) cut to -/// [`MAX_VALUE`] characters, as values a script sets are, with a note when it was. -pub(crate) fn clip_result(out: &mut XfaOutcome) { - if let Some(r) = out.result.take() { - let (r, cut) = clip_value(r); - if cut { - out.notes.push(format!("the script's result was longer than {MAX_VALUE} characters; it was cut")); - } - out.result = Some(r); - } -} - -/// One node of the flattened form, shared by the JavaScript and FormCalc engines. -pub(crate) struct HNode { - pub(crate) name: String, - pub(crate) som: String, - pub(crate) kind: XfaKind, - pub(crate) value: String, - pub(crate) numeric: bool, - pub(crate) presence: String, - pub(crate) access: String, - pub(crate) repeatable: bool, - pub(crate) occur_min: usize, - pub(crate) occur_max: Option, - pub(crate) index: usize, - pub(crate) parent: Option, - pub(crate) children: Vec, - /// Removed by `removeInstance`: skipped everywhere. - pub(crate) gone: bool, - /// See [`XfaNode::formatted`]; dropped when a script sets the value, which it no longer shows. - pub(crate) formatted: Option, -} - -/// The form as a script sees and changes it, with the effects it records. -pub(crate) struct XHost { - pub(crate) nodes: Vec, - pub(crate) effects: Vec, - pub(crate) console: Vec, - pub(crate) doc: XfaDoc, - added: usize, - /// Index of `this`. - pub(crate) current: usize, - /// Where the effect setting a property of an object is in `effects`, by (property, SOM): - /// setting it again replaces that effect (the last value wins) unless something that - /// depends on order (rows added or removed, a reset) came after it. - set_at: HashMap<(u8, String), usize>, - /// Effects before this index are behind an order-dependent effect. - barrier: usize, - alerts: usize, - dropped_effects: usize, - clipped_values: usize, - dropped_alerts: usize, - dropped_console: usize, - /// Bytes of text kept in effects, console lines and values written, against [`MAX_PAYLOAD`]. - kept: usize, - dropped_bytes: usize, -} - -impl XHost { - /// The host for a script on `root` (the template's root subform), with `this` at the node - /// named by `target`. `xfa.form` is a synthetic root whose child is the root subform. - pub(crate) fn new(root: XfaNode, target: &str, doc: &XfaDoc) -> XHost { - let form = XfaNode { name: "form".into(), kind: Some(XfaKind::Form), children: vec![root], ..Default::default() }; - let mut nodes = Vec::new(); - flatten(&form, None, &mut nodes, 0); - let current = nodes.iter().position(|n| n.som == target && n.kind != XfaKind::Form).unwrap_or(1.min(nodes.len().saturating_sub(1))); - XHost { - nodes, - effects: Vec::new(), - console: Vec::new(), - doc: doc.clone(), - added: 0, - current, - set_at: HashMap::new(), - barrier: 0, - alerts: 0, - dropped_effects: 0, - clipped_values: 0, - dropped_alerts: 0, - dropped_console: 0, - kept: 0, - dropped_bytes: 0, - } - } - - /// Room for `bytes` more bytes of kept text: charged when there is, a drop counted when not. - fn room(&mut self, bytes: usize) -> bool { - match self.kept.checked_add(bytes).filter(|k| *k <= MAX_PAYLOAD) { - Some(k) => { - self.kept = k; - true - } - None => { - self.dropped_bytes = self.dropped_bytes.saturating_add(1); - false - } - } - } - - /// Record an effect: values, presence and access set again on the same object replace the - /// earlier one; past [`MAX_EFFECTS`] (or [`MAX_ALERTS`] message boxes, or [`MAX_PAYLOAD`] - /// bytes of text) effects are counted and dropped. - pub(crate) fn push(&mut self, e: XfaEffect) { - let e = self.clip_effect(e); - if self.room(payload(&e)) { - self.record(e); - } - } - - /// Room for `e`, which holds `bytes` of text, charged now: a change to the snapshot is made - /// only when the effect that applies it will be kept, so a script never sees a change its - /// effects leave out. An effect that replaces an earlier one needs no new slot. A drop is - /// counted when there is no room. - fn reserve(&mut self, e: &XfaEffect, bytes: usize) -> bool { - let replaces = effect_key(e).and_then(|k| self.set_at.get(&k)).is_some_and(|&at| at >= self.barrier && at < self.effects.len()); - if !replaces && self.effects.len() >= MAX_EFFECTS { - self.dropped_effects += 1; - return false; - } - self.room(bytes) - } - - /// Set the presence or access of node `i` to what `e` (a [`XfaEffect::SetPresence`] or - /// [`XfaEffect::SetAccess`]) says, in the snapshot only when the effect is kept. - pub(crate) fn set_state(&mut self, i: usize, e: XfaEffect) { - if !self.reserve(&e, payload(&e)) { - return; - } - if let Some(n) = self.nodes.get_mut(i) { - match &e { - XfaEffect::SetPresence { presence, .. } => n.presence = presence.clone(), - XfaEffect::SetAccess { access, .. } => n.access = access.clone(), - _ => {} - } - } - self.record(e); - } - - /// `e` with its text cut to the lengths an effect keeps. - fn clip_effect(&mut self, e: XfaEffect) -> XfaEffect { - match e { - XfaEffect::MessageBox(m) => XfaEffect::MessageBox(clip(m)), - XfaEffect::LaunchUrl(u) => XfaEffect::LaunchUrl(clip(u)), - XfaEffect::SetFocus(s) => XfaEffect::SetFocus(clip(s)), - XfaEffect::ResetData(names) => XfaEffect::ResetData(names.into_iter().map(clip).collect()), - XfaEffect::SetValue { som, value } => { - let (value, cut) = clip_value(value); - if cut { - self.clipped_values += 1; - } - XfaEffect::SetValue { som, value } - } - other => other, - } - } - - /// Keep `e`, whose bytes are already charged, against the count limits. - fn record(&mut self, e: XfaEffect) { - if let Some(key) = effect_key(&e) { - if let Some(&at) = self.set_at.get(&key) - && at >= self.barrier - && let Some(slot) = self.effects.get_mut(at) - { - *slot = e; - return; - } - if self.effects.len() >= MAX_EFFECTS { - self.dropped_effects += 1; - return; - } - self.set_at.insert(key, self.effects.len()); - self.effects.push(e); - return; - } - if matches!(e, XfaEffect::MessageBox(_)) { - if self.alerts >= MAX_ALERTS { - self.dropped_alerts += 1; - return; - } - self.alerts += 1; - } - if self.effects.len() >= MAX_EFFECTS { - self.dropped_effects += 1; - return; - } - if matches!(e, XfaEffect::AddInstance { .. } | XfaEffect::RemoveInstance { .. } | XfaEffect::ResetData(_)) { - self.barrier = self.effects.len() + 1; - } - self.effects.push(e); - } - - pub(crate) fn print(&mut self, line: String) { - if self.console.len() >= MAX_CONSOLE { - self.dropped_console += 1; - } else if self.room(line.len()) { - self.console.push(line); - } - } - - /// What the limits left out. - pub(crate) fn notes(&self) -> Vec { - let mut out = Vec::new(); - if self.dropped_effects > 0 { - out.push(format!("the script made more than {MAX_EFFECTS} changes; {} more were left out", self.dropped_effects)); - } - if self.dropped_alerts > 0 { - out.push(format!("the script showed more than {MAX_ALERTS} messages; {} more were left out", self.dropped_alerts)); - } - if self.dropped_console > 0 { - out.push(format!("the script printed more than {MAX_CONSOLE} console lines; {} more were left out", self.dropped_console)); - } - if self.dropped_bytes > 0 { - out.push(format!("the script's changes and output passed {} MiB of text; {} more were left out", MAX_PAYLOAD >> 20, self.dropped_bytes)); - } - if self.clipped_values > 0 { - out.push(format!("the script set {} value(s) longer than {MAX_VALUE} characters; they were cut", self.clipped_values)); - } - out - } - - /// Give node `i` the value `text` when it is a field or exclusion group (nothing else - /// holds a value; writing one into a subform would put text where another viewer expects - /// a data group). Returns whether it was. - pub(crate) fn set_value(&mut self, i: usize, text: String) -> bool { - let Some(n) = self.nodes.get(i) else { return false }; - if n.gone || !matches!(n.kind, XfaKind::Field | XfaKind::ExclGroup) { - return false; - } - let som = n.som.clone(); - // Cut here, not only in the effect: later reads in the script see what is saved, and - // the snapshot stays bounded however often a long value is written. - let (text, cut) = clip_value(text); - if cut { - self.clipped_values += 1; - } - // The snapshot's copy and the effect's are charged together, before either is kept: - // past the byte budget or the effect cap the write is left out of both. - let effect = XfaEffect::SetValue { som, value: text.clone() }; - if !self.reserve(&effect, text.len().saturating_add(payload(&effect))) { - return true; - } - if let Some(n) = self.nodes.get_mut(i) { - n.value = text; - n.formatted = None; - } - self.record(effect); - true - } -} - -type Shared = RefCell; - -/// The form host of the script running in `ctx`. -fn host(ctx: &Context) -> JsResult<&Shared> { - ctx.get_data::().ok_or_else(|| error("the XFA script host is not set up")) -} - -/// The snapshot `n` and its descendants flattened into `out` under `parent` (to [`MAX_DEPTH`] -/// and [`MAX_NODES`]): the index of `n`'s node, `None` past the limits. -fn flatten(n: &XfaNode, parent: Option, out: &mut Vec, depth: usize) -> Option { - if depth > MAX_DEPTH || out.len() >= MAX_NODES { - return None; - } - let idx = out.len(); - out.push(HNode { - name: n.name.clone(), - som: n.som.clone(), - kind: n.kind.unwrap_or(XfaKind::Subform), - value: n.value.clone(), - numeric: n.numeric, - presence: if n.presence.is_empty() { "visible".into() } else { n.presence.clone() }, - access: if n.access.is_empty() { "open".into() } else { n.access.clone() }, - repeatable: n.repeatable, - occur_min: n.occur_min, - occur_max: n.occur_max, - index: n.index, - parent, - children: Vec::new(), - gone: false, - formatted: n.formatted.clone(), - }); - let mut kids = Vec::new(); - for c in &n.children { - if let Some(ci) = flatten(c, Some(idx), out, depth + 1) { - kids.push(ci); - } - } - if let Some(h) = out.get_mut(idx) { - h.children = kids; - } - Some(idx) -} - -// ── node objects ──────────────────────────────────────────────────────────────────────────── - -const IDX: &str = "__xfaNode"; - -fn node_index(target: &JsValue, ctx: &mut Context) -> JsResult { - let o = target.as_object().ok_or_else(|| error("not an XFA node"))?; - let v = o.get(JsString::from(IDX), ctx)?; - let n = v.to_number(ctx)?; - if !n.is_finite() || n < 0.0 { - return Err(error("not an XFA node")); - } - Ok(n as usize) -} - -/// A Proxy standing for node `idx`. -fn node_object(ctx: &mut Context, idx: usize) -> JsResult { - let target = ObjectInitializer::new(ctx).property(JsString::from(IDX), JsValue::from(idx as f64), Attribute::READONLY).build(); - let proxy = JsProxy::builder(target).get(node_get).set(node_set).has(node_has).build(ctx)?; - Ok(proxy.into()) -} - -/// An object that takes any property read or write without complaint (`this.border.fill…`, -/// `this.fillColor = …`): what a script sets there changes nothing here. -fn sink(ctx: &mut Context) -> JsResult { - let target = ObjectInitializer::new(ctx).build(); - let proxy = JsProxy::builder(target).get(sink_get).set(sink_set).build(ctx)?; - Ok(proxy.into()) -} - -fn sink_get(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { - let key = arg(args, 1); - if key.as_symbol().is_some() { - return Ok(JsValue::undefined()); - } - match text(&key, ctx)?.as_str() { - "value" | "toString" | "valueOf" => Ok(s("")), - _ => sink(ctx), - } -} - -fn sink_set(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { - Ok(JsValue::from(true)) -} - -fn node_has(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { - Ok(JsValue::from(true)) -} - -fn value_js(h: &HNode) -> JsValue { - if h.value.is_empty() { - return JsValue::null(); - } - if h.numeric - && let Some(n) = as_number(&h.value) - { - return JsValue::from(n); - } - s(&h.value) -} - -/// `(idx, name)` of the child of `parent` named `name`, first instance. -pub(crate) fn child_named(h: &XHost, parent: usize, name: &str) -> Option { - let p = h.nodes.get(parent)?; - p.children.iter().copied().find(|&c| h.nodes.get(c).is_some_and(|n| !n.gone && n.name == name)) -} - -/// Every live instance named `name` under `parent`, in order. -pub(crate) fn instances(h: &XHost, parent: usize, name: &str) -> Vec { - h.nodes - .get(parent) - .map(|p| p.children.iter().copied().filter(|&c| h.nodes.get(c).is_some_and(|n| !n.gone && n.name == name)).collect()) - .unwrap_or_default() -} - -fn list_object(ctx: &mut Context, items: &[usize]) -> JsResult { - let vals: Vec = items.iter().map(|i| JsValue::from(*i as f64)).collect(); - let arr = JsArray::from_iter(vals, ctx); - let arr_obj: JsObject = arr.into(); - fn item(_: &JsValue, args: &[JsValue], arr: &JsObject, ctx: &mut Context) -> JsResult { - let i = arg(args, 0).to_number(ctx)?; - if !i.is_finite() || i < 0.0 { - return Ok(JsValue::null()); - } - let v = JsArray::from_object(arr.clone())?.get(i as u64, ctx)?; - if v.is_undefined() { - return Ok(JsValue::null()); - } - let idx = v.to_number(ctx)? as usize; - node_object(ctx, idx) - } - let item_fn = function(ctx, NativeFunction::from_copy_closure_with_captures(item, arr_obj)); - Ok(ObjectInitializer::new(ctx) - .property(js_string!("length"), JsValue::from(items.len() as f64), Attribute::READONLY) - .property(js_string!("item"), item_fn, Attribute::all()) - .build() - .into()) -} - -/// The instance manager of the subform named `name` under `parent`. -fn instance_manager(ctx: &mut Context, parent: usize, name: &str) -> JsResult { - let (count, min, max) = { - let h = host(ctx)?.borrow(); - let live = instances(&h, parent, name); - let first = live.first().and_then(|i| h.nodes.get(*i)); - (live.len(), first.map_or(0, |n| n.occur_min), first.and_then(|n| n.occur_max)) - }; - let key = ObjectInitializer::new(ctx) - .property(js_string!("parent"), JsValue::from(parent as f64), Attribute::READONLY) - .property(js_string!("name"), s(name), Attribute::READONLY) - .build(); - fn target_of(key: &JsObject, ctx: &mut Context) -> JsResult<(usize, String)> { - let p = key.get(js_string!("parent"), ctx)?.to_number(ctx)? as usize; - let n = text(&key.get(js_string!("name"), ctx)?, ctx)?; - Ok((p, n)) - } - fn add_instance(_: &JsValue, _: &[JsValue], key: &JsObject, ctx: &mut Context) -> JsResult { - let (p, n) = target_of(key, ctx)?; - match add_instance_of(ctx, p, &n)? { - Some(i) => node_object(ctx, i), - None => Ok(JsValue::null()), - } - } - fn insert_instance(this: &JsValue, args: &[JsValue], key: &JsObject, ctx: &mut Context) -> JsResult { - add_instance(this, args, key, ctx) - } - fn remove_instance(_: &JsValue, args: &[JsValue], key: &JsObject, ctx: &mut Context) -> JsResult { - let (p, n) = target_of(key, ctx)?; - let i = arg(args, 0).to_number(ctx)?; - if i.is_finite() && i >= 0.0 { - remove_instance_of(ctx, p, &n, i as usize)?; - } - Ok(JsValue::undefined()) - } - fn set_instances(_: &JsValue, args: &[JsValue], key: &JsObject, ctx: &mut Context) -> JsResult { - let (p, n) = target_of(key, ctx)?; - let want = arg(args, 0).to_number(ctx)?; - if !want.is_finite() || want < 0.0 { - return Ok(JsValue::undefined()); - } - host(ctx)?.borrow_mut().set_instances(p, &n, want as usize); - Ok(JsValue::undefined()) - } - fn move_instance(_: &JsValue, _: &[JsValue], _: &JsObject, _: &mut Context) -> JsResult { - Ok(JsValue::undefined()) - } - fn count_get(_: &JsValue, _: &[JsValue], key: &JsObject, ctx: &mut Context) -> JsResult { - let (p, n) = target_of(key, ctx)?; - let c = instances(&host(ctx)?.borrow(), p, &n).len(); - Ok(JsValue::from(c as f64)) - } - fn count_set(_: &JsValue, args: &[JsValue], key: &JsObject, ctx: &mut Context) -> JsResult { - set_instances(&JsValue::undefined(), args, key, ctx) - } - let count_g = function(ctx, NativeFunction::from_copy_closure_with_captures(count_get, key.clone())); - let count_s = function(ctx, NativeFunction::from_copy_closure_with_captures(count_set, key.clone())); - let add = function(ctx, NativeFunction::from_copy_closure_with_captures(add_instance, key.clone())); - let insert = function(ctx, NativeFunction::from_copy_closure_with_captures(insert_instance, key.clone())); - let remove = function(ctx, NativeFunction::from_copy_closure_with_captures(remove_instance, key.clone())); - let set = function(ctx, NativeFunction::from_copy_closure_with_captures(set_instances, key.clone())); - let mv = function(ctx, NativeFunction::from_copy_closure_with_captures(move_instance, key)); - let _ = count; - let o = ObjectInitializer::new(ctx) - .property(js_string!("min"), JsValue::from(min as f64), Attribute::READONLY) - .property(js_string!("max"), max.map_or(JsValue::from(-1.0), |m| JsValue::from(m as f64)), Attribute::READONLY) - .property(js_string!("addInstance"), add, Attribute::all()) - .property(js_string!("insertInstance"), insert, Attribute::all()) - .property(js_string!("removeInstance"), remove, Attribute::all()) - .property(js_string!("setInstances"), set, Attribute::all()) - .property(js_string!("moveInstance"), mv, Attribute::all()) - .property(js_string!("className"), s("instanceManager"), Attribute::READONLY) - .build(); - use boa_engine::property::PropertyDescriptor; - o.define_property_or_throw(js_string!("count"), PropertyDescriptor::builder().get(count_g).set(count_s).configurable(true), ctx)?; - Ok(o.into()) -} - -/// Re-derive the SOM of `idx` and everything under it from its parent and index. -fn renumber(h: &mut XHost, idx: usize, depth: usize) { - if depth > MAX_DEPTH { - return; - } - let (parent_som, name, index, kids) = { - let Some(n) = h.nodes.get(idx) else { return }; - let parent_som = n.parent.and_then(|p| h.nodes.get(p)).map(|p| p.som.clone()).unwrap_or_default(); - (parent_som, n.name.clone(), n.index, n.children.clone()) - }; - let som = child_som(parent_som, &name, index); - if let Some(n) = h.nodes.get_mut(idx) { - n.som = som; - } - for k in kids { - renumber(h, k, depth + 1); - } -} - -/// The SOM of instance `index` of the node `name` under a parent at `parent_som`. -fn child_som(parent_som: String, name: &str, index: usize) -> String { - if name.starts_with('#') || name.is_empty() { - parent_som - } else if parent_som.is_empty() { - format!("{name}[{index}]") - } else { - format!("{parent_som}.{name}[{index}]") - } -} - -/// A copy of `src`'s subtree (values cleared) under `parent`, returning the copy's index. -fn clone_subtree(h: &mut XHost, src: usize, parent: Option, depth: usize) -> Option { - if depth > MAX_DEPTH || h.nodes.len() >= MAX_NODES { - return None; - } - let (template, kids) = { - let n = h.nodes.get(src)?; - ( - HNode { - name: n.name.clone(), - som: String::new(), - kind: n.kind, - value: String::new(), - numeric: n.numeric, - presence: n.presence.clone(), - access: n.access.clone(), - repeatable: n.repeatable, - occur_min: n.occur_min, - occur_max: n.occur_max, - index: n.index, - parent, - children: Vec::new(), - gone: false, - // The copy holds no value, so nothing is shown in its picture either. - formatted: None, - }, - n.children.clone(), - ) - }; - let idx = h.nodes.len(); - h.nodes.push(template); - let mut copied = Vec::new(); - for k in kids { - // Nested repeating subforms copy their first instance only. - let skip = h.nodes.get(k).is_some_and(|c| c.gone || c.repeatable && c.index > 0); - if skip { - continue; - } - if let Some(ci) = clone_subtree(h, k, Some(idx), depth + 1) { - copied.push(ci); - } - } - if let Some(n) = h.nodes.get_mut(idx) { - n.children = copied; - } - Some(idx) -} - -fn add_instance_of(ctx: &mut Context, parent: usize, name: &str) -> JsResult> { - let mut h = host(ctx)?.borrow_mut(); - Ok(h.add_instance(parent, name)) -} - -fn remove_instance_of(ctx: &mut Context, parent: usize, name: &str, i: usize) -> JsResult<()> { - let mut h = host(ctx)?.borrow_mut(); - h.remove_instance(parent, name, i); - Ok(()) -} - -impl XHost { - /// A new instance of the subform `name` under `parent`, after the last one; `None` when the - /// subform doesn't repeat, is at its maximum, or the run added its limit. - pub(crate) fn add_instance(&mut self, parent: usize, name: &str) -> Option { - let h = self; - let live = instances(h, parent, name); - let &first = live.first()?; - let (repeatable, max) = h.nodes.get(first).map(|n| (n.repeatable, n.occur_max)).unwrap_or((false, Some(1))); - if !repeatable || max.is_some_and(|m| live.len() >= m) || h.added >= MAX_ADDED || h.nodes.len() >= MAX_NODES { - return None; - } - // The copy's SOM, as `renumber` will give it, so its effect is reserved before the copy. - let name = h.nodes.get(first).map(|n| n.name.clone()).unwrap_or_default(); - let parent_som = h.nodes.get(parent).map(|p| p.som.clone()).unwrap_or_default(); - let som = child_som(parent_som, &name, live.len()); - if !h.reserve(&XfaEffect::AddInstance { som: String::new() }, som.len()) { - return None; - } - let new = clone_subtree(h, first, Some(parent), 0)?; - let last = live.last().copied().unwrap_or(first); - if let Some(n) = h.nodes.get_mut(new) { - n.index = live.len(); - } - // Keep instances together: right after the last one. - if let Some(p) = h.nodes.get_mut(parent) { - let pos = p.children.iter().position(|&c| c == last).map_or(p.children.len(), |i| i + 1); - p.children.insert(pos, new); - } - renumber(h, new, 0); - h.added += 1; - h.record(XfaEffect::AddInstance { som }); - Some(new) - } - - /// Add or remove instances of the subform `name` under `parent` until there are `want`. - /// Bounded: adding stops at the cap or the maximum, removing at the floor, and a step that - /// changes nothing ends the loop. - pub(crate) fn set_instances(&mut self, parent: usize, name: &str, want: usize) { - let want = want.min(MAX_ADDED); - for _ in 0..=MAX_ADDED { - let have = instances(self, parent, name).len(); - if have < want { - if self.add_instance(parent, name).is_none() { - break; - } - } else if have > want && have > 0 { - self.remove_instance(parent, name, have - 1); - if instances(self, parent, name).len() == have { - break; - } - } else { - break; - } - } - } - - /// Remove instance `i` of the subform `name` under `parent` (never below the minimum, or - /// below one). - pub(crate) fn remove_instance(&mut self, parent: usize, name: &str, i: usize) { - let h = self; - let live = instances(h, parent, name); - let Some(&gone) = live.get(i) else { return }; - let min = h.nodes.get(gone).map_or(0, |n| n.occur_min).max(1); - if live.len() <= min { - return; - } - let som = h.nodes.get(gone).map(|n| n.som.clone()).unwrap_or_default(); - if !h.reserve(&XfaEffect::RemoveInstance { som: String::new() }, som.len()) { - return; - } - if let Some(n) = h.nodes.get_mut(gone) { - n.gone = true; - } - // Later instances move down one. - for (k, &inst) in live.iter().enumerate().skip(i + 1) { - if let Some(n) = h.nodes.get_mut(inst) { - n.index = k - 1; - } - renumber(h, inst, 0); - } - h.record(XfaEffect::RemoveInstance { som }); - } -} - -/// Resolve a SOM expression from node `from`: `$`, `$form`, `$record`, `$host`, `xfa.form…`, -/// `parent`, `..name` (descendant search), `name[n]`, `name[*]`, `_name`. -pub(crate) fn resolve_som(h: &XHost, from: usize, expr: &str) -> Vec { - let expr = expr.trim(); - if expr.is_empty() { - return vec![from]; - } - let root = 0usize; - let mut cur: Vec = vec![from]; - let mut rest = expr; - let starts = - [("$form", root), ("xfa.form", root), ("$record", root), ("xfa.datasets.data", root), ("xfa.record", root), ("$", from), ("this", from)]; - for (prefix, at) in starts { - if let Some(r) = rest.strip_prefix(prefix) - && (r.is_empty() || r.starts_with('.') || r.starts_with('[')) - { - cur = vec![at]; - rest = r; - break; - } - } - let mut depth = 0; - while !rest.is_empty() && depth < MAX_DEPTH { - depth += 1; - let descend = rest.starts_with(".."); - rest = rest.trim_start_matches('.'); - let end = rest.find(['.', '[']).unwrap_or(rest.len()); - let (name, after) = rest.split_at(end); - let name = name.trim(); - let (index, after) = match after.strip_prefix('[') { - Some(a) => { - let close = a.find(']').unwrap_or(a.len()); - let idx = a.get(..close).map(str::trim).unwrap_or(""); - (Some(idx.to_string()), a.get(close + 1..).unwrap_or("")) - } - None => (None, after), - }; - rest = after; - let mut next = Vec::new(); - for &c in &cur { - match name { - "" => {} - "parent" => { - if let Some(p) = h.nodes.get(c).and_then(|n| n.parent) { - next.push(p); - } - } - _ => { - let mut found: Vec = if descend { descendants_named(h, c, name, 0) } else { instances(h, c, name) }; - // A relative name a node has no child for is looked for among its siblings - // and in each enclosing container, nearest first (so a field's script can - // say `resolveNode("otherField")`). - if found.is_empty() && depth == 1 && !descend { - let mut at = h.nodes.get(c).and_then(|n| n.parent); - let mut guard = 0; - while let Some(p) = at - && guard < MAX_DEPTH - { - found = instances(h, p, name); - if !found.is_empty() { - break; - } - at = h.nodes.get(p).and_then(|n| n.parent); - guard += 1; - } - } - match index.as_deref() { - Some("*") => next.extend(found), - Some(i) => { - if let Some(k) = i.parse::().ok().and_then(|i| found.get(i)) { - next.push(*k); - } - } - None => next.extend(found.first()), - } - } - } - } - // Each node once: `row[*].parent.row[*]` would otherwise multiply the list by the - // number of rows at every step. - let mut seen = std::collections::HashSet::with_capacity(next.len()); - next.retain(|i| seen.insert(*i)); - cur = next; - if cur.is_empty() { - break; - } - } - cur -} - -/// The live descendants of node `from` named `name` (`from..name` in a SOM path), in document -/// order, to [`MAX_DEPTH`]. -pub(crate) fn descendants_named(h: &XHost, from: usize, name: &str, depth: usize) -> Vec { - let mut out = Vec::new(); - if depth > MAX_DEPTH { - return out; - } - let Some(n) = h.nodes.get(from) else { return out }; - for &c in &n.children { - if let Some(k) = h.nodes.get(c) - && !k.gone - { - if k.name == name { - out.push(c); - } else { - out.extend(descendants_named(h, c, name, depth + 1)); - } - } - } - out -} - -/// A property read on a form node's Proxy: its value (and `formattedValue` as its picture shows -/// it), attributes, relatives, methods, instance managers and children by name. -fn node_get(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { - let idx = node_index(&arg(args, 0), ctx)?; - let key = arg(args, 1); - if key.as_symbol().is_some() { - return Ok(JsValue::undefined()); - } - let key = text(&key, ctx)?; - let snapshot = { - let h = host(ctx)?.borrow(); - let Some(n) = h.nodes.get(idx) else { return Ok(JsValue::undefined()) }; - (n.name.clone(), n.som.clone(), n.kind, n.value.clone(), n.presence.clone(), n.access.clone(), n.index, n.parent, n.children.clone()) - }; - let (name, som, kind, value, presence, access, index, parent, children) = snapshot; - Ok(match key.as_str() { - IDX => JsValue::from(idx as f64), - "rawValue" | "value" => host(ctx)?.borrow().nodes.get(idx).map_or(JsValue::null(), value_js), - "formattedValue" => s(&host(ctx)?.borrow().nodes.get(idx).and_then(|n| n.formatted.clone()).unwrap_or(value)), - "name" => s(&name), - "somExpression" => s(&som), - "className" => s(kind.class_name()), - "index" | "instanceIndex" => JsValue::from(index as f64), - "presence" => s(&presence), - "access" => s(&access), - "isNull" => JsValue::from(value.is_empty()), - "mandatory" => s("disabled"), - "parent" => match parent { - Some(p) => node_object(ctx, p)?, - None => JsValue::null(), - }, - "nodes" => { - let live: Vec = { - let h = host(ctx)?.borrow(); - children.iter().copied().filter(|&c| h.nodes.get(c).is_some_and(|n| !n.gone)).collect() - }; - list_object(ctx, &live)? - } - "all" => { - let live = match parent { - Some(p) => instances(&host(ctx)?.borrow(), p, &name), - None => vec![idx], - }; - list_object(ctx, &live)? - } - "instanceManager" => match parent { - Some(p) => instance_manager(ctx, p, &name)?, - None => JsValue::undefined(), - }, - "resolveNode" => resolver(ctx, idx, false)?, - "resolveNodes" => resolver(ctx, idx, true)?, - "recalculate" => function(ctx, NativeFunction::from_fn_ptr(recalculate)).into(), - "remerge" => function(ctx, NativeFunction::from_fn_ptr(relayout)).into(), - "getAttribute" - | "setAttribute" - | "execEvent" - | "execInitialize" - | "execCalculate" - | "execValidate" - | "getElement" - | "setElement" - | "isPropertySpecified" - | "loadXML" - | "saveXML" - | "clone" => function(ctx, NativeFunction::from_fn_ptr(noop)).into(), - "toString" | "valueOf" | "toJSON" => function(ctx, NativeFunction::from_fn_ptr(node_to_string)).into(), - "length" => JsValue::undefined(), - "border" | "ui" | "font" | "caption" | "fillColor" | "borderColor" | "fontColor" | "margin" | "para" | "assist" | "validate" | "format" - | "items" | "bind" | "keep" | "h" | "w" | "x" | "y" | "minH" | "maxH" | "minW" | "maxW" | "layout" | "model" | "oneOfChild" | "locale" - | "relevant" | "colSpan" | "dataNode" | "defaultValue" | "editValue" | "selectedIndex" | "event" | "calculate" | "traversal" | "extras" - | "desc" => sink(ctx)?, - other => { - if let Some(child) = other.strip_prefix('_') { - let exists = child_named(&host(ctx)?.borrow(), idx, child).is_some(); - return if exists { instance_manager(ctx, idx, child) } else { Ok(JsValue::undefined()) }; - } - let found = child_named(&host(ctx)?.borrow(), idx, other); - match found { - Some(c) => node_object(ctx, c)?, - None => JsValue::undefined(), - } - } - }) -} - -fn node_set(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { - let idx = node_index(&arg(args, 0), ctx)?; - let key = arg(args, 1); - if key.as_symbol().is_some() { - return Ok(JsValue::from(true)); - } - let key = text(&key, ctx)?; - let value = arg(args, 2); - let value = if value.is_null() || value.is_undefined() { String::new() } else { text(&value, ctx)? }; - let mut h = host(ctx)?.borrow_mut(); - let Some(n) = h.nodes.get(idx) else { return Ok(JsValue::from(true)) }; - let som = n.som.clone(); - match key.as_str() { - "rawValue" | "value" | "formattedValue" => { - // Not a field: the property doesn't exist, as in Acrobat, and nothing is recorded. - h.set_value(idx, value); - } - "presence" => { - let p = match value.as_str() { - "hidden" | "invisible" | "inactive" | "visible" => value.clone(), - _ => return Ok(JsValue::from(true)), - }; - h.set_state(idx, XfaEffect::SetPresence { som, presence: p }); - } - "access" => { - let a = match value.as_str() { - "open" | "readOnly" | "protected" | "nonInteractive" => value.clone(), - _ => return Ok(JsValue::from(true)), - }; - h.set_state(idx, XfaEffect::SetAccess { som, access: a }); - } - _ => {} - } - Ok(JsValue::from(true)) -} - -fn node_to_string(this: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { - let idx = node_index(this, ctx)?; - Ok(s(&host(ctx)?.borrow().nodes.get(idx).map(|n| n.value.clone()).unwrap_or_default())) -} - -fn noop(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { - Ok(JsValue::undefined()) -} - -/// `resolveNode(som)` / `resolveNodes(som)` bound to node `from`. -fn resolver(ctx: &mut Context, from: usize, many: bool) -> JsResult { - let key = ObjectInitializer::new(ctx) - .property(js_string!("from"), JsValue::from(from as f64), Attribute::READONLY) - .property(js_string!("many"), JsValue::from(many), Attribute::READONLY) - .build(); - fn resolve(_: &JsValue, args: &[JsValue], key: &JsObject, ctx: &mut Context) -> JsResult { - let from = key.get(js_string!("from"), ctx)?.to_number(ctx)? as usize; - let many = key.get(js_string!("many"), ctx)?.to_boolean(); - let expr = text(&arg(args, 0), ctx)?; - let found = resolve_som(&host(ctx)?.borrow(), from, &expr); - if many { - return list_object(ctx, &found); - } - match found.first() { - Some(i) => node_object(ctx, *i), - None => Ok(JsValue::null()), - } - } - Ok(function(ctx, NativeFunction::from_copy_closure_with_captures(resolve, key)).into()) -} - -// ── xfa.host, xfa.layout, xfa.event, app, console ─────────────────────────────────────────── - -/// Records effect `e` on the host, as [`XHost::push`] does. -fn effect(ctx: &mut Context, e: XfaEffect) -> JsResult<()> { - host(ctx)?.borrow_mut().push(e); - Ok(()) -} - -/// `xfa.host.messageBox(message)`: records the message, cut to [`MAX_LINE`] characters, and -/// returns 1. -fn message_box(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { - let m = clip(text(&arg(args, 0), ctx)?); - effect(ctx, XfaEffect::MessageBox(m))?; - Ok(JsValue::from(1.0)) -} - -/// `xfa.host.resetData(names)`: asks to reset the comma-separated fields, or every field when -/// the list is empty. -fn reset_data(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { - let list = text(&arg(args, 0), ctx)?; - effect(ctx, XfaEffect::ResetData(reset_names(&list)))?; - Ok(JsValue::undefined()) -} - -/// `xfa.host.print(…)`: asks to print; its arguments are ignored. -fn print(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { - effect(ctx, XfaEffect::Print)?; - Ok(JsValue::undefined()) -} - -/// `xfa.host.beep()`: asks to beep. -fn beep(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { - effect(ctx, XfaEffect::Beep)?; - Ok(JsValue::undefined()) -} - -/// `xfa.host.gotoURL(url)`: asks to open the URL. -fn goto_url(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { - let u = text(&arg(args, 0), ctx)?; - effect(ctx, XfaEffect::LaunchUrl(u))?; - Ok(JsValue::undefined()) -} - -/// `xfa.host.setFocus(target)`: asks to focus the node, given as a node object or as a SOM -/// expression resolved from the current node; an expression that names no node is passed on -/// as written. -fn set_focus(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { - let target = arg(args, 0); - let som = match target.as_object() { - Some(_) if node_index(&target, ctx).is_ok() => { - let i = node_index(&target, ctx)?; - host(ctx)?.borrow().nodes.get(i).map(|n| n.som.clone()).unwrap_or_default() - } - _ => { - let expr = text(&target, ctx)?; - let h = host(ctx)?.borrow(); - let found = resolve_som(&h, h.current, &expr); - found.first().and_then(|i| h.nodes.get(*i)).map(|n| n.som.clone()).unwrap_or(expr) - } - }; - effect(ctx, XfaEffect::SetFocus(som))?; - Ok(JsValue::undefined()) -} - -/// `app.execMenuItem(name)`: the effect for the request the shared menu table gives the name -/// (Save opens Save As too); an unknown name does nothing. -fn exec_menu_item(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { - let item = text(&arg(args, 0), ctx)?; - match crate::menu_item(&item) { - Some(crate::Request::SaveAs) => effect(ctx, XfaEffect::SaveAs)?, - Some(crate::Request::Print) => effect(ctx, XfaEffect::Print)?, - Some(crate::Request::ShowPanel(p)) => effect(ctx, XfaEffect::ShowPanel(p))?, - _ => {} - } - Ok(JsValue::undefined()) -} - -fn recalculate(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { - effect(ctx, XfaEffect::Recalculate)?; - Ok(JsValue::undefined()) -} - -fn relayout(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { - effect(ctx, XfaEffect::Relayout)?; - Ok(JsValue::undefined()) -} - -fn console_println(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { - let m = clip(text(&arg(args, 0), ctx)?); - host(ctx)?.borrow_mut().print(m); - Ok(JsValue::undefined()) -} - -fn response(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { - Ok(JsValue::null()) -} - -fn layout_page(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { - Ok(JsValue::from(host(ctx)?.borrow().doc.page as f64 + 1.0)) -} - -fn layout_page0(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { - Ok(JsValue::from(host(ctx)?.borrow().doc.page as f64)) -} - -fn layout_page_count(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { - Ok(JsValue::from(host(ctx)?.borrow().doc.page_count as f64)) -} - -fn zero(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { - Ok(JsValue::from(0.0)) -} - -fn xfa_resolve(ctx: &mut Context, many: bool) -> JsResult { - let current = host(ctx)?.borrow().current; - resolver(ctx, current, many) -} - -/// Defines the globals for one script on node `current`: `xfa` (`form`, `host`, `layout`, `event`, -/// `resolveNode` and more), `event` as the same object as `xfa.event`, `app`, `console`, `util`, -/// and the nodes in scope by name, with `_name` for each repeatable one's instance manager. -fn install(ctx: &mut Context, event: &XfaEvent, doc: &XfaDoc, current: usize) -> JsResult<()> { - let rw = Attribute::CONFIGURABLE; - let form = node_object(ctx, 0)?; - let this_node = node_object(ctx, current)?; - let host_obj = ObjectInitializer::new(ctx) - .function(NativeFunction::from_fn_ptr(message_box), js_string!("messageBox"), 4) - .function(NativeFunction::from_fn_ptr(reset_data), js_string!("resetData"), 1) - .function(NativeFunction::from_fn_ptr(print), js_string!("print"), 8) - .function(NativeFunction::from_fn_ptr(beep), js_string!("beep"), 1) - .function(NativeFunction::from_fn_ptr(goto_url), js_string!("gotoURL"), 2) - .function(NativeFunction::from_fn_ptr(set_focus), js_string!("setFocus"), 1) - .function(NativeFunction::from_fn_ptr(response), js_string!("response"), 4) - .function(NativeFunction::from_fn_ptr(noop), js_string!("openList"), 1) - .function(NativeFunction::from_fn_ptr(noop), js_string!("exportData"), 2) - .function(NativeFunction::from_fn_ptr(noop), js_string!("importData"), 1) - .function(NativeFunction::from_fn_ptr(noop), js_string!("pageDown"), 0) - .function(NativeFunction::from_fn_ptr(noop), js_string!("pageUp"), 0) - .function(NativeFunction::from_fn_ptr(noop), js_string!("getFocus"), 0) - .property(js_string!("currentPage"), JsValue::from(doc.page as f64), rw) - .property(js_string!("numPages"), JsValue::from(doc.page_count as f64), rw) - .property(js_string!("name"), s("Acrobat"), Attribute::READONLY) - .property(js_string!("appType"), s("Exchange-Pro"), Attribute::READONLY) - .property(js_string!("version"), s("11"), Attribute::READONLY) - .property(js_string!("variation"), s("Full"), Attribute::READONLY) - .property(js_string!("language"), s("en"), Attribute::READONLY) - .property(js_string!("platform"), s(platform()), Attribute::READONLY) - .property(js_string!("calculationsEnabled"), JsValue::from(true), rw) - .property(js_string!("validationsEnabled"), JsValue::from(true), rw) - .property(js_string!("title"), s(&doc.file_name), rw) - .build(); - let layout = ObjectInitializer::new(ctx) - .function(NativeFunction::from_fn_ptr(layout_page), js_string!("page"), 1) - .function(NativeFunction::from_fn_ptr(layout_page0), js_string!("absPage"), 1) - .function(NativeFunction::from_fn_ptr(layout_page_count), js_string!("pageCount"), 0) - .function(NativeFunction::from_fn_ptr(layout_page_count), js_string!("absPageCount"), 0) - .function(NativeFunction::from_fn_ptr(relayout), js_string!("relayout"), 0) - .function(NativeFunction::from_fn_ptr(relayout), js_string!("relayoutPageArea"), 0) - .function(NativeFunction::from_fn_ptr(zero), js_string!("h"), 2) - .function(NativeFunction::from_fn_ptr(zero), js_string!("w"), 2) - .function(NativeFunction::from_fn_ptr(zero), js_string!("x"), 2) - .function(NativeFunction::from_fn_ptr(zero), js_string!("y"), 2) - .function(NativeFunction::from_fn_ptr(zero), js_string!("pageSpan"), 1) - .property(js_string!("ready"), JsValue::from(true), Attribute::READONLY) - .build(); - let ev = ObjectInitializer::new(ctx) - .property(js_string!("name"), s(&event.activity), Attribute::all()) - .property(js_string!("newText"), s(&event.new_text), Attribute::all()) - .property(js_string!("prevText"), s(&event.prev_text), Attribute::all()) - .property(js_string!("fullText"), s(&event.new_text), Attribute::all()) - .property(js_string!("change"), s(""), Attribute::all()) - .property(js_string!("target"), this_node.clone(), Attribute::all()) - .property(js_string!("selStart"), JsValue::from(0.0), Attribute::all()) - .property(js_string!("selEnd"), JsValue::from(0.0), Attribute::all()) - .property(js_string!("cancelAction"), JsValue::from(false), Attribute::all()) - .property(js_string!("reenter"), JsValue::from(false), Attribute::all()) - .property(js_string!("shift"), JsValue::from(false), Attribute::all()) - .property(js_string!("modifier"), JsValue::from(false), Attribute::all()) - .property(js_string!("keyDown"), JsValue::from(false), Attribute::all()) - .property(js_string!("commitKey"), JsValue::from(0.0), Attribute::all()) - .function(NativeFunction::from_fn_ptr(noop), js_string!("emit"), 0) - .function(NativeFunction::from_fn_ptr(noop), js_string!("reset"), 0) - .build(); - let datasets = ObjectInitializer::new(ctx).property(js_string!("data"), form.clone(), rw).build(); - let template = sink(ctx)?; - let resolve_one = xfa_resolve(ctx, false)?; - let resolve_many = xfa_resolve(ctx, true)?; - let xfa = ObjectInitializer::new(ctx) - .property(js_string!("form"), form.clone(), rw) - .property(js_string!("host"), host_obj, rw) - .property(js_string!("layout"), layout, rw) - .property(js_string!("event"), ev.clone(), rw) - .property(js_string!("datasets"), datasets, rw) - .property(js_string!("record"), form.clone(), rw) - .property(js_string!("template"), template, rw) - .property(js_string!("resolveNode"), resolve_one, rw) - .property(js_string!("resolveNodes"), resolve_many, rw) - .function(NativeFunction::from_fn_ptr(recalculate), js_string!("recalculate"), 1) - .function(NativeFunction::from_fn_ptr(relayout), js_string!("remerge"), 0) - .build(); - ctx.register_global_property(js_string!("xfa"), xfa, rw)?; - ctx.register_global_property(js_string!("event"), ev, rw)?; - ctx.register_global_property(js_string!("__xfa_this"), this_node, rw)?; - let app = ObjectInitializer::new(ctx) - .function(NativeFunction::from_fn_ptr(message_box), js_string!("alert"), 4) - .function(NativeFunction::from_fn_ptr(beep), js_string!("beep"), 1) - .function(NativeFunction::from_fn_ptr(response), js_string!("response"), 1) - .function(NativeFunction::from_fn_ptr(goto_url), js_string!("launchURL"), 2) - .function(NativeFunction::from_fn_ptr(exec_menu_item), js_string!("execMenuItem"), 1) - .function(NativeFunction::from_fn_ptr(noop), js_string!("setTimeOut"), 2) - .function(NativeFunction::from_fn_ptr(noop), js_string!("setInterval"), 2) - .function(NativeFunction::from_fn_ptr(noop), js_string!("clearTimeOut"), 1) - .function(NativeFunction::from_fn_ptr(noop), js_string!("clearInterval"), 1) - .property(js_string!("viewerType"), s("Exchange-Pro"), Attribute::READONLY) - .property(js_string!("viewerVariation"), s("Full"), Attribute::READONLY) - .property(js_string!("viewerVersion"), JsValue::from(24.0), Attribute::READONLY) - .property(js_string!("platform"), s(platform()), Attribute::READONLY) - .property(js_string!("language"), s("ENU"), Attribute::READONLY) - .build(); - ctx.register_global_property(js_string!("app"), app, rw)?; - let console = ObjectInitializer::new(ctx) - .function(NativeFunction::from_fn_ptr(console_println), js_string!("println"), 1) - .function(NativeFunction::from_fn_ptr(noop), js_string!("show"), 0) - .function(NativeFunction::from_fn_ptr(noop), js_string!("hide"), 0) - .function(NativeFunction::from_fn_ptr(noop), js_string!("clear"), 0) - .build(); - ctx.register_global_property(js_string!("console"), console, rw)?; - let util = ObjectInitializer::new(ctx) - .function(NativeFunction::from_fn_ptr(printf), js_string!("printf"), 1) - .function(NativeFunction::from_fn_ptr(printd), js_string!("printd"), 2) - .function(NativeFunction::from_fn_ptr(printx), js_string!("printx"), 2) - .function(NativeFunction::from_fn_ptr(scand), js_string!("scand"), 2) - .build(); - ctx.register_global_property(js_string!("util"), util, rw)?; - // Unqualified names: the current node's children, its siblings, then each ancestor's - // children, the nearest first. Instance managers as `_name`. - let scopes: Vec = { - let h = host(ctx)?.borrow(); - let mut out = vec![current]; - let mut at = h.nodes.get(current).and_then(|n| n.parent); - let mut guard = 0; - while let Some(p) = at - && guard < MAX_DEPTH - { - out.push(p); - at = h.nodes.get(p).and_then(|n| n.parent); - guard += 1; - } - out - }; - // Names that stay what JavaScript (or this host) means by them: a field called `Date` - // is reached as `this.parent.Date` or by resolveNode. - const RESERVED: &[&str] = &[ - "xfa", - "event", - "app", - "console", - "util", - "this", - "__xfa_this", - "__xfa_src", - "eval", - "arguments", - "globalThis", - "window", - "undefined", - "NaN", - "Infinity", - "Object", - "Function", - "Array", - "Number", - "String", - "Boolean", - "Symbol", - "Date", - "RegExp", - "Error", - "TypeError", - "RangeError", - "SyntaxError", - "Math", - "JSON", - "Map", - "Set", - "WeakMap", - "WeakSet", - "Promise", - "Proxy", - "Reflect", - "parseInt", - "parseFloat", - "isNaN", - "isFinite", - "encodeURIComponent", - "decodeURIComponent", - "encodeURI", - "decodeURI", - "escape", - "unescape", - "BigInt", - "ArrayBuffer", - "DataView", - "Intl", - ]; - let mut defined: Vec = RESERVED.iter().map(ToString::to_string).collect(); - for scope in scopes { - let kids: Vec<(usize, String, bool)> = { - let h = host(ctx)?.borrow(); - h.nodes - .get(scope) - .map(|n| n.children.iter().filter_map(|&c| h.nodes.get(c).map(|k| (c, k.name.clone(), k.repeatable && !k.gone))).collect()) - .unwrap_or_default() - }; - for (c, name, repeatable) in kids { - if !is_identifier(&name) || defined.contains(&name) { - continue; - } - let obj = node_object(ctx, c)?; - ctx.register_global_property(JsString::from(name.as_str()), obj, rw)?; - if repeatable { - let im = instance_manager(ctx, scope, &name)?; - ctx.register_global_property(JsString::from(format!("_{name}").as_str()), im, rw)?; - } - defined.push(name); - } - } - Ok(()) -} - -fn is_identifier(name: &str) -> bool { - let mut chars = name.chars(); - match chars.next() { - Some(c) if c.is_ascii_alphabetic() || c == '_' || c == '$' => {} - _ => return false, - } - chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$') - && !matches!( - name, - "var" - | "let" - | "const" - | "function" - | "return" - | "if" - | "else" - | "for" - | "while" - | "new" - | "null" - | "true" - | "false" - | "undefined" - | "typeof" - ) -} - -/// Run `script` for `event` on the form `root` (the root subform), from node `event.target`. -pub fn run_xfa(script: &str, event: &XfaEvent, doc: &XfaDoc, root: &XfaNode, limits: Limits) -> XfaOutcome { - let failed = |why: String| XfaOutcome { error: Some(why), ..Default::default() }; - if let Some(why) = refuse(script) { - return failed(why); - } - #[cfg(not(target_arch = "wasm32"))] - { - std::thread::scope(|scope| { - let spawned = std::thread::Builder::new() - .name("pdfcraft-xfa-js".into()) - .stack_size(crate::SCRIPT_STACK) - .spawn_scoped(scope, || run_here(script, event, doc, root.clone(), limits, &|| {})); - match spawned { - Ok(thread) => thread.join().unwrap_or_else(|_| failed("the script stopped with an internal error".into())), - Err(e) => failed(format!("the script engine could not start: {e}")), - } - }) - } - #[cfg(target_arch = "wasm32")] - run_here(script, event, doc, root.clone(), limits, &|| {}) -} - -/// [`run_xfa`] with a time limit: a script still running `timeout` after the engine is ready -/// (loops inside nested function calls can run for hours within the engine's per-frame loop -/// limit) is left on its thread, which ends when the engine's own limits stop it, and an -/// outcome with `abandoned` set comes back at once. In the browser build there are no -/// threads: the script runs to its limits. -pub fn run_xfa_within(script: &str, event: &XfaEvent, doc: &XfaDoc, root: XfaNode, limits: Limits, timeout: std::time::Duration) -> XfaOutcome { - if let Some(why) = refuse(script) { - return XfaOutcome { error: Some(why), ..Default::default() }; - } - let (script, event, doc) = (script.to_string(), event.clone(), doc.clone()); - run_within("pdfcraft-xfa-js", timeout, move |started| run_here(&script, &event, &doc, root, limits, started)) -} - -/// Longest the script engine may take to be ready for a script (its thread and context, and -/// for the first script in a process the engine's own set-up) before the script is abandoned. -/// It doesn't count against the script's time: on a busy machine the start alone can take -/// longer than the tightest limit, and abandoning a script turns the form's scripts off. -pub(crate) const ENGINE_START_LIMIT: std::time::Duration = std::time::Duration::from_secs(10); - -/// Run `script` on its own thread (with the script stack) and wait at most `timeout` for its -/// outcome, counted from when it calls `started` (once the engine is ready); past that the -/// thread is abandoned and the outcome says so. On wasm there are no threads: the script runs -/// here. -pub(crate) fn run_within(name: &str, timeout: std::time::Duration, script: impl FnOnce(&dyn Fn()) -> XfaOutcome + Send + 'static) -> XfaOutcome { - run_within_limits(name, ENGINE_START_LIMIT, timeout, script) -} - -/// [`run_within`] with the engine's start-up limit given. -pub(crate) fn run_within_limits( - name: &str, - start_limit: std::time::Duration, - timeout: std::time::Duration, - script: impl FnOnce(&dyn Fn()) -> XfaOutcome + Send + 'static, -) -> XfaOutcome { - #[cfg(not(target_arch = "wasm32"))] - { - use std::sync::mpsc::RecvTimeoutError; - use std::time::Instant; - enum Step { - Started, - Done(XfaOutcome), - } - let failed = |why: String| XfaOutcome { error: Some(why), ..Default::default() }; - let abandoned = |why: String| XfaOutcome { error: Some(why), abandoned: true, ..Default::default() }; - let (tx, rx) = std::sync::mpsc::channel(); - let spawned = std::thread::Builder::new().name(name.into()).stack_size(crate::SCRIPT_STACK).spawn(move || { - // The receiver is gone when the caller stopped waiting: nothing to report then. - let started = tx.clone(); - let outcome = script(&move || { - let _ = started.send(Step::Started); - }); - let _ = tx.send(Step::Done(outcome)); - }); - if let Err(e) = spawned { - return failed(format!("the script engine could not start: {e}")); - } - // Wait for the engine within its start-up limit, then for the script within its own. - let (mut running, mut limit) = (false, start_limit); - let mut deadline = Instant::now().checked_add(limit); - loop { - let left = deadline.map_or(limit, |d| d.saturating_duration_since(Instant::now())); - match rx.recv_timeout(left) { - Ok(Step::Done(o)) => return o, - Ok(Step::Started) => { - // A second call changes nothing: the script's time has already begun. - if !running { - (running, limit) = (true, timeout); - deadline = Instant::now().checked_add(limit); - } - } - Err(RecvTimeoutError::Timeout) if running => { - return abandoned(format!("the script ran longer than {:.1} s and was abandoned", timeout.as_secs_f64())); - } - Err(RecvTimeoutError::Timeout) => { - return abandoned(format!("the script engine took longer than {:.0} s to start", start_limit.as_secs_f64())); - } - Err(RecvTimeoutError::Disconnected) => return failed("the script stopped with an internal error".into()), - } - } - } - #[cfg(target_arch = "wasm32")] - { - let _ = (name, start_limit, timeout); - script(&|| {}) - } -} - -/// The first script in a process sets the engine up (its built-ins, parser and compiler come -/// into use): a trivial script takes that on, so a form's script is never timed with it. -pub(crate) fn warm_up() { - static WARM: std::sync::Once = std::sync::Once::new(); - // `call_once_force`: a poisoned `Once` (a panic while warming up) only means warming up again. - WARM.call_once_force(|_| { - let mut ctx = Context::default(); - let _ = ctx.eval(Source::from_bytes(b"(function () { return eval('0'); }).call({})")); - }); -} - -/// `started` is called once the engine is ready: a time limit on the script runs from then. -pub(crate) fn run_here(script: &str, event: &XfaEvent, doc: &XfaDoc, root: XfaNode, limits: Limits, started: &dyn Fn()) -> XfaOutcome { - warm_up(); - let mut ctx = match Context::builder().host_hooks(std::rc::Rc::new(crate::CheckedCodeFromStrings)).build() { - Ok(ctx) => ctx, - Err(e) => return XfaOutcome { error: Some(format!("the script engine could not start: {e}")), ..Default::default() }, - }; - ctx.runtime_limits_mut().set_loop_iteration_limit(limits.loop_iterations); - ctx.runtime_limits_mut().set_recursion_limit(limits.recursion); - let host_state = XHost::new(root, &event.target, doc); - let current = host_state.current; - ctx.insert_data(RefCell::new(host_state)); - let mut out = XfaOutcome::default(); - let result = (|| -> JsResult<()> { - install(&mut ctx, event, doc, current)?; - // Ready for the script: its time starts now, the set-up above not charged to it. - started(); - ctx.register_global_property(js_string!("__xfa_src"), s(script), Attribute::CONFIGURABLE)?; - // Direct eval inside a function called on the node: `this` is the node and the - // completion value is the script's last expression (what a calculate script yields). - let r = ctx.eval(Source::from_bytes(b"(function () { return eval(__xfa_src); }).call(__xfa_this)"))?; - if !r.is_undefined() { - // Best effort: a value that can't be made text (a symbol, say) is no failure. - out.result = Some(text(&r, &mut ctx).unwrap_or_default()); - if r.is_boolean() { - out.result_bool = Some(r.to_boolean()); - } else if r.is_null() { - out.result = Some(String::new()); - } - } - Ok(()) - })(); - if let Err(e) = result { - out.error = Some(e.to_string()); - } - if let Some(h) = ctx.remove_data::() { - let h = h.into_inner(); - out.notes = h.notes(); - out.effects = h.effects; - out.console = h.console; - } - clip_result(&mut out); - out -} +use crate::{Limits, Message, arg, as_number, error, function, platform, printd, printf, printx, refuse, s, scand, text}; + +mod globals; +mod host; +mod instances; +mod model; +mod nodes; +mod run; + +pub(crate) use host::{HNode, XHost}; +pub use model::{MAX_ALERTS, MAX_CONSOLE, MAX_EFFECTS, XfaDoc, XfaEffect, XfaEvent, XfaKind, XfaNode, XfaOutcome}; +pub(crate) use model::{MAX_LINE, MAX_PAYLOAD, clip, clip_result, reset_names}; +pub(crate) use nodes::{child_named, descendants_named, instances, resolve_som}; +#[cfg(test)] +pub(crate) use run::run_within_limits; +#[cfg(not(target_arch = "wasm32"))] +pub(crate) use run::{ENGINE_START_LIMIT, warm_up}; +pub(crate) use run::{run_here, run_within}; +pub use run::{run_xfa, run_xfa_within}; diff --git a/crates/js/src/xfa/globals.rs b/crates/js/src/xfa/globals.rs new file mode 100644 index 0000000000..971cf12fc1 --- /dev/null +++ b/crates/js/src/xfa/globals.rs @@ -0,0 +1,376 @@ +use super::host::host; +use super::instances::instance_manager; +use super::model::{MAX_DEPTH, XfaDoc, XfaEffect, XfaEvent, clip, reset_names}; +use super::nodes::{node_index, node_object, noop, resolve_som, resolver, sink}; +use super::{ + Attribute, Context, JsResult, JsString, JsValue, NativeFunction, ObjectInitializer, arg, js_string, platform, printd, printf, printx, s, scand, + text, +}; + +// ── xfa.host, xfa.layout, xfa.event, app, console ─────────────────────────────────────────── + +/// Records effect `e` on the host, against the run's limits, as [`XHost::push`] does. +fn effect(ctx: &mut Context, e: XfaEffect) -> JsResult<()> { + host(ctx)?.borrow_mut().push(e); + Ok(()) +} + +/// `xfa.host.messageBox(message, title, icon, buttons)`: records the message with its title, +/// icon and buttons, its text and title cut to [`MAX_LINE`] characters, and returns 1. +fn message_box(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let m = crate::globals::message_box_message(args, ctx)?; + effect(ctx, XfaEffect::MessageBox(m))?; + // Always OK for now: the viewer can't ask the user and run the script on with the answer + // yet (#4291). + Ok(JsValue::from(1.0)) +} + +/// `app.alert(cMsg, nIcon, nType, cTitle)` in an XFA form's script: the same message box, its +/// arguments in `app.alert`'s order (or one object with those keys). +fn app_alert(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let m = crate::globals::alert_message(args, ctx)?; + effect(ctx, XfaEffect::MessageBox(m))?; + // The OK button, as for `messageBox` (#4291). + Ok(JsValue::from(1.0)) +} + +/// `xfa.host.resetData(names)`: asks to reset the comma-separated fields, or every field when +/// the list is empty. +fn reset_data(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let list = text(&arg(args, 0), ctx)?; + effect(ctx, XfaEffect::ResetData(reset_names(&list)))?; + Ok(JsValue::undefined()) +} + +/// `xfa.host.print(…)`: asks to print; its arguments are ignored. +fn print(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { + effect(ctx, XfaEffect::Print)?; + Ok(JsValue::undefined()) +} + +/// `xfa.host.beep()`: asks to beep. +fn beep(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { + effect(ctx, XfaEffect::Beep)?; + Ok(JsValue::undefined()) +} + +/// `xfa.host.gotoURL(url)`: asks to open the URL. +fn goto_url(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let u = text(&arg(args, 0), ctx)?; + effect(ctx, XfaEffect::LaunchUrl(u))?; + Ok(JsValue::undefined()) +} + +/// `xfa.host.setFocus(target)`: asks to focus the node, given as a node object or as a SOM +/// expression resolved from the current node; an expression that names no node is passed on +/// as written. +fn set_focus(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let target = arg(args, 0); + let som = match target.as_object() { + Some(_) if node_index(&target, ctx).is_ok() => { + let i = node_index(&target, ctx)?; + host(ctx)?.borrow().nodes.get(i).map(|n| n.som.clone()).unwrap_or_default() + } + _ => { + let expr = text(&target, ctx)?; + let h = host(ctx)?.borrow(); + let found = resolve_som(&h, h.current, &expr); + found.first().and_then(|i| h.nodes.get(*i)).map(|n| n.som.clone()).unwrap_or(expr) + } + }; + effect(ctx, XfaEffect::SetFocus(som))?; + Ok(JsValue::undefined()) +} + +/// `app.execMenuItem(name)`: the effect for the request the shared menu table gives the name +/// (Save opens Save As too); an unknown name does nothing. +fn exec_menu_item(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let item = text(&arg(args, 0), ctx)?; + match crate::menu_item(&item) { + Some(crate::Request::SaveAs) => effect(ctx, XfaEffect::SaveAs)?, + Some(crate::Request::Print) => effect(ctx, XfaEffect::Print)?, + Some(crate::Request::ShowPanel(p)) => effect(ctx, XfaEffect::ShowPanel(p))?, + _ => {} + } + Ok(JsValue::undefined()) +} + +pub(super) fn recalculate(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { + effect(ctx, XfaEffect::Recalculate)?; + Ok(JsValue::undefined()) +} + +pub(super) fn relayout(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { + effect(ctx, XfaEffect::Relayout)?; + Ok(JsValue::undefined()) +} + +fn console_println(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let m = clip(text(&arg(args, 0), ctx)?); + host(ctx)?.borrow_mut().print(m); + Ok(JsValue::undefined()) +} + +fn response(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { + Ok(JsValue::null()) +} + +fn layout_page(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { + Ok(JsValue::from(host(ctx)?.borrow().doc.page as f64 + 1.0)) +} + +fn layout_page0(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { + Ok(JsValue::from(host(ctx)?.borrow().doc.page as f64)) +} + +fn layout_page_count(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { + Ok(JsValue::from(host(ctx)?.borrow().doc.page_count as f64)) +} + +fn zero(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { + Ok(JsValue::from(0.0)) +} + +fn xfa_resolve(ctx: &mut Context, many: bool) -> JsResult { + let current = host(ctx)?.borrow().current; + resolver(ctx, current, many) +} + +/// Defines the globals for one script on node `current`: `xfa` (`form`, `host`, `layout`, `event`, +/// `resolveNode` and more), `event` as the same object as `xfa.event`, `app`, `console`, `util`, +/// and the nodes in scope by name, with `_name` for each repeatable one's instance manager. +pub(super) fn install(ctx: &mut Context, event: &XfaEvent, doc: &XfaDoc, current: usize) -> JsResult<()> { + let rw = Attribute::CONFIGURABLE; + let form = node_object(ctx, 0)?; + let this_node = node_object(ctx, current)?; + let host_obj = ObjectInitializer::new(ctx) + .function(NativeFunction::from_fn_ptr(message_box), js_string!("messageBox"), 4) + .function(NativeFunction::from_fn_ptr(reset_data), js_string!("resetData"), 1) + .function(NativeFunction::from_fn_ptr(print), js_string!("print"), 8) + .function(NativeFunction::from_fn_ptr(beep), js_string!("beep"), 1) + .function(NativeFunction::from_fn_ptr(goto_url), js_string!("gotoURL"), 2) + .function(NativeFunction::from_fn_ptr(set_focus), js_string!("setFocus"), 1) + .function(NativeFunction::from_fn_ptr(response), js_string!("response"), 4) + .function(NativeFunction::from_fn_ptr(noop), js_string!("openList"), 1) + .function(NativeFunction::from_fn_ptr(noop), js_string!("exportData"), 2) + .function(NativeFunction::from_fn_ptr(noop), js_string!("importData"), 1) + .function(NativeFunction::from_fn_ptr(noop), js_string!("pageDown"), 0) + .function(NativeFunction::from_fn_ptr(noop), js_string!("pageUp"), 0) + .function(NativeFunction::from_fn_ptr(noop), js_string!("getFocus"), 0) + .property(js_string!("currentPage"), JsValue::from(doc.page as f64), rw) + .property(js_string!("numPages"), JsValue::from(doc.page_count as f64), rw) + .property(js_string!("name"), s("Acrobat"), Attribute::READONLY) + .property(js_string!("appType"), s("Exchange-Pro"), Attribute::READONLY) + .property(js_string!("version"), s("11"), Attribute::READONLY) + .property(js_string!("variation"), s("Full"), Attribute::READONLY) + .property(js_string!("language"), s("en"), Attribute::READONLY) + .property(js_string!("platform"), s(platform()), Attribute::READONLY) + .property(js_string!("calculationsEnabled"), JsValue::from(true), rw) + .property(js_string!("validationsEnabled"), JsValue::from(true), rw) + .property(js_string!("title"), s(&doc.file_name), rw) + .build(); + let layout = ObjectInitializer::new(ctx) + .function(NativeFunction::from_fn_ptr(layout_page), js_string!("page"), 1) + .function(NativeFunction::from_fn_ptr(layout_page0), js_string!("absPage"), 1) + .function(NativeFunction::from_fn_ptr(layout_page_count), js_string!("pageCount"), 0) + .function(NativeFunction::from_fn_ptr(layout_page_count), js_string!("absPageCount"), 0) + .function(NativeFunction::from_fn_ptr(relayout), js_string!("relayout"), 0) + .function(NativeFunction::from_fn_ptr(relayout), js_string!("relayoutPageArea"), 0) + .function(NativeFunction::from_fn_ptr(zero), js_string!("h"), 2) + .function(NativeFunction::from_fn_ptr(zero), js_string!("w"), 2) + .function(NativeFunction::from_fn_ptr(zero), js_string!("x"), 2) + .function(NativeFunction::from_fn_ptr(zero), js_string!("y"), 2) + .function(NativeFunction::from_fn_ptr(zero), js_string!("pageSpan"), 1) + .property(js_string!("ready"), JsValue::from(true), Attribute::READONLY) + .build(); + let ev = ObjectInitializer::new(ctx) + .property(js_string!("name"), s(&event.activity), Attribute::all()) + .property(js_string!("newText"), s(&event.new_text), Attribute::all()) + .property(js_string!("prevText"), s(&event.prev_text), Attribute::all()) + .property(js_string!("fullText"), s(&event.new_text), Attribute::all()) + .property(js_string!("change"), s(""), Attribute::all()) + .property(js_string!("target"), this_node.clone(), Attribute::all()) + .property(js_string!("selStart"), JsValue::from(0.0), Attribute::all()) + .property(js_string!("selEnd"), JsValue::from(0.0), Attribute::all()) + .property(js_string!("cancelAction"), JsValue::from(false), Attribute::all()) + .property(js_string!("reenter"), JsValue::from(false), Attribute::all()) + .property(js_string!("shift"), JsValue::from(false), Attribute::all()) + .property(js_string!("modifier"), JsValue::from(false), Attribute::all()) + .property(js_string!("keyDown"), JsValue::from(false), Attribute::all()) + .property(js_string!("commitKey"), JsValue::from(0.0), Attribute::all()) + .function(NativeFunction::from_fn_ptr(noop), js_string!("emit"), 0) + .function(NativeFunction::from_fn_ptr(noop), js_string!("reset"), 0) + .build(); + let datasets = ObjectInitializer::new(ctx).property(js_string!("data"), form.clone(), rw).build(); + let template = sink(ctx)?; + let resolve_one = xfa_resolve(ctx, false)?; + let resolve_many = xfa_resolve(ctx, true)?; + let xfa = ObjectInitializer::new(ctx) + .property(js_string!("form"), form.clone(), rw) + .property(js_string!("host"), host_obj, rw) + .property(js_string!("layout"), layout, rw) + .property(js_string!("event"), ev.clone(), rw) + .property(js_string!("datasets"), datasets, rw) + .property(js_string!("record"), form.clone(), rw) + .property(js_string!("template"), template, rw) + .property(js_string!("resolveNode"), resolve_one, rw) + .property(js_string!("resolveNodes"), resolve_many, rw) + .function(NativeFunction::from_fn_ptr(recalculate), js_string!("recalculate"), 1) + .function(NativeFunction::from_fn_ptr(relayout), js_string!("remerge"), 0) + .build(); + ctx.register_global_property(js_string!("xfa"), xfa, rw)?; + ctx.register_global_property(js_string!("event"), ev, rw)?; + ctx.register_global_property(js_string!("__xfa_this"), this_node, rw)?; + let app = ObjectInitializer::new(ctx) + .function(NativeFunction::from_fn_ptr(app_alert), js_string!("alert"), 4) + .function(NativeFunction::from_fn_ptr(beep), js_string!("beep"), 1) + .function(NativeFunction::from_fn_ptr(response), js_string!("response"), 1) + .function(NativeFunction::from_fn_ptr(goto_url), js_string!("launchURL"), 2) + .function(NativeFunction::from_fn_ptr(exec_menu_item), js_string!("execMenuItem"), 1) + .function(NativeFunction::from_fn_ptr(noop), js_string!("setTimeOut"), 2) + .function(NativeFunction::from_fn_ptr(noop), js_string!("setInterval"), 2) + .function(NativeFunction::from_fn_ptr(noop), js_string!("clearTimeOut"), 1) + .function(NativeFunction::from_fn_ptr(noop), js_string!("clearInterval"), 1) + .property(js_string!("viewerType"), s("Exchange-Pro"), Attribute::READONLY) + .property(js_string!("viewerVariation"), s("Full"), Attribute::READONLY) + .property(js_string!("viewerVersion"), JsValue::from(24.0), Attribute::READONLY) + .property(js_string!("platform"), s(platform()), Attribute::READONLY) + .property(js_string!("language"), s("ENU"), Attribute::READONLY) + .build(); + ctx.register_global_property(js_string!("app"), app, rw)?; + let console = ObjectInitializer::new(ctx) + .function(NativeFunction::from_fn_ptr(console_println), js_string!("println"), 1) + .function(NativeFunction::from_fn_ptr(noop), js_string!("show"), 0) + .function(NativeFunction::from_fn_ptr(noop), js_string!("hide"), 0) + .function(NativeFunction::from_fn_ptr(noop), js_string!("clear"), 0) + .build(); + ctx.register_global_property(js_string!("console"), console, rw)?; + let util = ObjectInitializer::new(ctx) + .function(NativeFunction::from_fn_ptr(printf), js_string!("printf"), 1) + .function(NativeFunction::from_fn_ptr(printd), js_string!("printd"), 2) + .function(NativeFunction::from_fn_ptr(printx), js_string!("printx"), 2) + .function(NativeFunction::from_fn_ptr(scand), js_string!("scand"), 2) + .build(); + ctx.register_global_property(js_string!("util"), util, rw)?; + // Unqualified names: the current node's children, its siblings, then each ancestor's + // children, the nearest first. Instance managers as `_name`. + let scopes: Vec = { + let h = host(ctx)?.borrow(); + let mut out = vec![current]; + let mut at = h.nodes.get(current).and_then(|n| n.parent); + let mut guard = 0; + while let Some(p) = at + && guard < MAX_DEPTH + { + out.push(p); + at = h.nodes.get(p).and_then(|n| n.parent); + guard += 1; + } + out + }; + // Names that stay what JavaScript (or this host) means by them: a field called `Date` + // is reached as `this.parent.Date` or by resolveNode. + const RESERVED: &[&str] = &[ + "xfa", + "event", + "app", + "console", + "util", + "this", + "__xfa_this", + "__xfa_src", + "eval", + "arguments", + "globalThis", + "window", + "undefined", + "NaN", + "Infinity", + "Object", + "Function", + "Array", + "Number", + "String", + "Boolean", + "Symbol", + "Date", + "RegExp", + "Error", + "TypeError", + "RangeError", + "SyntaxError", + "Math", + "JSON", + "Map", + "Set", + "WeakMap", + "WeakSet", + "Promise", + "Proxy", + "Reflect", + "parseInt", + "parseFloat", + "isNaN", + "isFinite", + "encodeURIComponent", + "decodeURIComponent", + "encodeURI", + "decodeURI", + "escape", + "unescape", + "BigInt", + "ArrayBuffer", + "DataView", + "Intl", + ]; + let mut defined: Vec = RESERVED.iter().map(ToString::to_string).collect(); + for scope in scopes { + let kids: Vec<(usize, String, bool)> = { + let h = host(ctx)?.borrow(); + h.nodes + .get(scope) + .map(|n| n.children.iter().filter_map(|&c| h.nodes.get(c).map(|k| (c, k.name.clone(), k.repeatable && !k.gone))).collect()) + .unwrap_or_default() + }; + for (c, name, repeatable) in kids { + if !is_identifier(&name) || defined.contains(&name) { + continue; + } + let obj = node_object(ctx, c)?; + ctx.register_global_property(JsString::from(name.as_str()), obj, rw)?; + if repeatable { + let im = instance_manager(ctx, scope, &name)?; + ctx.register_global_property(JsString::from(format!("_{name}").as_str()), im, rw)?; + } + defined.push(name); + } + } + Ok(()) +} + +fn is_identifier(name: &str) -> bool { + let mut chars = name.chars(); + match chars.next() { + Some(c) if c.is_ascii_alphabetic() || c == '_' || c == '$' => {} + _ => return false, + } + chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$') + && !matches!( + name, + "var" + | "let" + | "const" + | "function" + | "return" + | "if" + | "else" + | "for" + | "while" + | "new" + | "null" + | "true" + | "false" + | "undefined" + | "typeof" + ) +} diff --git a/crates/js/src/xfa/host.rs b/crates/js/src/xfa/host.rs new file mode 100644 index 0000000000..ec222a4ca8 --- /dev/null +++ b/crates/js/src/xfa/host.rs @@ -0,0 +1,286 @@ +use super::model::{ + MAX_ALERTS, MAX_CONSOLE, MAX_DEPTH, MAX_EFFECTS, MAX_NODES, MAX_PAYLOAD, MAX_VALUE, XfaDoc, XfaEffect, XfaKind, XfaNode, clip, clip_value, + effect_key, payload, +}; +use super::{Context, HashMap, JsResult, Message, RefCell, error}; + +/// One node of the flattened form, shared by the JavaScript and FormCalc engines. +pub(crate) struct HNode { + pub(crate) name: String, + pub(crate) som: String, + pub(crate) kind: XfaKind, + pub(crate) value: String, + pub(crate) numeric: bool, + pub(crate) presence: String, + pub(crate) access: String, + pub(crate) repeatable: bool, + pub(crate) occur_min: usize, + pub(crate) occur_max: Option, + pub(crate) index: usize, + pub(crate) parent: Option, + pub(crate) children: Vec, + /// Removed by `removeInstance`: skipped everywhere. + pub(crate) gone: bool, + /// See [`XfaNode::formatted`]; dropped when a script sets the value, which it no longer shows. + pub(crate) formatted: Option, +} + +/// The form as a script sees and changes it, with the effects it records. +pub(crate) struct XHost { + pub(crate) nodes: Vec, + pub(crate) effects: Vec, + pub(crate) console: Vec, + pub(crate) doc: XfaDoc, + pub(super) added: usize, + /// Index of `this`. + pub(crate) current: usize, + /// Where the effect setting a property of an object is in `effects`, by (property, SOM): + /// setting it again replaces that effect (the last value wins) unless something that + /// depends on order (rows added or removed, a reset) came after it. + set_at: HashMap<(u8, String), usize>, + /// Effects before this index are behind an order-dependent effect. + barrier: usize, + alerts: usize, + dropped_effects: usize, + clipped_values: usize, + dropped_alerts: usize, + dropped_console: usize, + /// Bytes of text kept in effects, console lines and values written, against [`MAX_PAYLOAD`]. + kept: usize, + dropped_bytes: usize, +} + +impl XHost { + /// The host for a script on `root` (the template's root subform), with `this` at the node + /// named by `target`. `xfa.form` is a synthetic root whose child is the root subform. + pub(crate) fn new(root: XfaNode, target: &str, doc: &XfaDoc) -> XHost { + let form = XfaNode { name: "form".into(), kind: Some(XfaKind::Form), children: vec![root], ..Default::default() }; + let mut nodes = Vec::new(); + flatten(&form, None, &mut nodes, 0); + let current = nodes.iter().position(|n| n.som == target && n.kind != XfaKind::Form).unwrap_or(1.min(nodes.len().saturating_sub(1))); + XHost { + nodes, + effects: Vec::new(), + console: Vec::new(), + doc: doc.clone(), + added: 0, + current, + set_at: HashMap::new(), + barrier: 0, + alerts: 0, + dropped_effects: 0, + clipped_values: 0, + dropped_alerts: 0, + dropped_console: 0, + kept: 0, + dropped_bytes: 0, + } + } + + /// Room for `bytes` more bytes of kept text: charged when there is, a drop counted when not. + fn room(&mut self, bytes: usize) -> bool { + match self.kept.checked_add(bytes).filter(|k| *k <= MAX_PAYLOAD) { + Some(k) => { + self.kept = k; + true + } + None => { + self.dropped_bytes = self.dropped_bytes.saturating_add(1); + false + } + } + } + + /// Record an effect: values, presence and access set again on the same object replace the + /// earlier one; past [`MAX_EFFECTS`] (or [`MAX_ALERTS`] message boxes, or [`MAX_PAYLOAD`] + /// bytes of text) effects are counted and dropped. + pub(crate) fn push(&mut self, e: XfaEffect) { + let e = self.clip_effect(e); + if self.room(payload(&e)) { + self.record(e); + } + } + + /// Room for `e`, which holds `bytes` of text, charged now: a change to the snapshot is made + /// only when the effect that applies it will be kept, so a script never sees a change its + /// effects leave out. An effect that replaces an earlier one needs no new slot. A drop is + /// counted when there is no room. + pub(super) fn reserve(&mut self, e: &XfaEffect, bytes: usize) -> bool { + let replaces = effect_key(e).and_then(|k| self.set_at.get(&k)).is_some_and(|&at| at >= self.barrier && at < self.effects.len()); + if !replaces && self.effects.len() >= MAX_EFFECTS { + self.dropped_effects += 1; + return false; + } + self.room(bytes) + } + + /// Set the presence or access of node `i` to what `e` (a [`XfaEffect::SetPresence`] or + /// [`XfaEffect::SetAccess`]) says, in the snapshot only when the effect is kept. + pub(crate) fn set_state(&mut self, i: usize, e: XfaEffect) { + if !self.reserve(&e, payload(&e)) { + return; + } + if let Some(n) = self.nodes.get_mut(i) { + match &e { + XfaEffect::SetPresence { presence, .. } => n.presence = presence.clone(), + XfaEffect::SetAccess { access, .. } => n.access = access.clone(), + _ => {} + } + } + self.record(e); + } + + /// `e` with its text cut to the lengths an effect keeps. + fn clip_effect(&mut self, e: XfaEffect) -> XfaEffect { + match e { + XfaEffect::MessageBox(m) => XfaEffect::MessageBox(Message { text: clip(m.text), title: m.title.map(clip), ..m }), + XfaEffect::LaunchUrl(u) => XfaEffect::LaunchUrl(clip(u)), + XfaEffect::SetFocus(s) => XfaEffect::SetFocus(clip(s)), + XfaEffect::ResetData(names) => XfaEffect::ResetData(names.into_iter().map(clip).collect()), + XfaEffect::SetValue { som, value } => { + let (value, cut) = clip_value(value); + if cut { + self.clipped_values += 1; + } + XfaEffect::SetValue { som, value } + } + other => other, + } + } + + /// Keep `e`, whose bytes are already charged, against the count limits. + pub(super) fn record(&mut self, e: XfaEffect) { + if let Some(key) = effect_key(&e) { + if let Some(&at) = self.set_at.get(&key) + && at >= self.barrier + && let Some(slot) = self.effects.get_mut(at) + { + *slot = e; + return; + } + if self.effects.len() >= MAX_EFFECTS { + self.dropped_effects += 1; + return; + } + self.set_at.insert(key, self.effects.len()); + self.effects.push(e); + return; + } + if matches!(e, XfaEffect::MessageBox(_)) { + if self.alerts >= MAX_ALERTS { + self.dropped_alerts += 1; + return; + } + self.alerts += 1; + } + if self.effects.len() >= MAX_EFFECTS { + self.dropped_effects += 1; + return; + } + if matches!(e, XfaEffect::AddInstance { .. } | XfaEffect::RemoveInstance { .. } | XfaEffect::ResetData(_)) { + self.barrier = self.effects.len() + 1; + } + self.effects.push(e); + } + + pub(crate) fn print(&mut self, line: String) { + if self.console.len() >= MAX_CONSOLE { + self.dropped_console += 1; + } else if self.room(line.len()) { + self.console.push(line); + } + } + + /// What the limits left out. + pub(crate) fn notes(&self) -> Vec { + let mut out = Vec::new(); + if self.dropped_effects > 0 { + out.push(format!("the script made more than {MAX_EFFECTS} changes; {} more were left out", self.dropped_effects)); + } + if self.dropped_alerts > 0 { + out.push(format!("the script showed more than {MAX_ALERTS} messages; {} more were left out", self.dropped_alerts)); + } + if self.dropped_console > 0 { + out.push(format!("the script printed more than {MAX_CONSOLE} console lines; {} more were left out", self.dropped_console)); + } + if self.dropped_bytes > 0 { + out.push(format!("the script's changes and output passed {} MiB of text; {} more were left out", MAX_PAYLOAD >> 20, self.dropped_bytes)); + } + if self.clipped_values > 0 { + out.push(format!("the script set {} value(s) longer than {MAX_VALUE} characters; they were cut", self.clipped_values)); + } + out + } + + /// Give node `i` the value `text` when it is a field or exclusion group (nothing else + /// holds a value; writing one into a subform would put text where another viewer expects + /// a data group). Returns whether it was. + pub(crate) fn set_value(&mut self, i: usize, text: String) -> bool { + let Some(n) = self.nodes.get(i) else { return false }; + if n.gone || !matches!(n.kind, XfaKind::Field | XfaKind::ExclGroup) { + return false; + } + let som = n.som.clone(); + // Cut here, not only in the effect: later reads in the script see what is saved, and + // the snapshot stays bounded however often a long value is written. + let (text, cut) = clip_value(text); + if cut { + self.clipped_values += 1; + } + // The snapshot's copy and the effect's are charged together, before either is kept: + // past the byte budget or the effect cap the write is left out of both. + let effect = XfaEffect::SetValue { som, value: text.clone() }; + if !self.reserve(&effect, text.len().saturating_add(payload(&effect))) { + return true; + } + if let Some(n) = self.nodes.get_mut(i) { + n.value = text; + n.formatted = None; + } + self.record(effect); + true + } +} + +pub(super) type Shared = RefCell; + +/// The form host of the script running in `ctx`. +pub(super) fn host(ctx: &Context) -> JsResult<&Shared> { + ctx.get_data::().ok_or_else(|| error("the XFA script host is not set up")) +} + +/// The snapshot `n` and its descendants flattened into `out` under `parent` (to [`MAX_DEPTH`] +/// and [`MAX_NODES`]): the index of `n`'s node, `None` past the limits. +fn flatten(n: &XfaNode, parent: Option, out: &mut Vec, depth: usize) -> Option { + if depth > MAX_DEPTH || out.len() >= MAX_NODES { + return None; + } + let idx = out.len(); + out.push(HNode { + name: n.name.clone(), + som: n.som.clone(), + kind: n.kind.unwrap_or(XfaKind::Subform), + value: n.value.clone(), + numeric: n.numeric, + presence: if n.presence.is_empty() { "visible".into() } else { n.presence.clone() }, + access: if n.access.is_empty() { "open".into() } else { n.access.clone() }, + repeatable: n.repeatable, + occur_min: n.occur_min, + occur_max: n.occur_max, + index: n.index, + parent, + children: Vec::new(), + gone: false, + formatted: n.formatted.clone(), + }); + let mut kids = Vec::new(); + for c in &n.children { + if let Some(ci) = flatten(c, Some(idx), out, depth + 1) { + kids.push(ci); + } + } + if let Some(h) = out.get_mut(idx) { + h.children = kids; + } + Some(idx) +} diff --git a/crates/js/src/xfa/instances.rs b/crates/js/src/xfa/instances.rs new file mode 100644 index 0000000000..c099c5abe8 --- /dev/null +++ b/crates/js/src/xfa/instances.rs @@ -0,0 +1,279 @@ +use super::host::{HNode, XHost, host}; +use super::model::{MAX_ADDED, MAX_DEPTH, MAX_NODES, XfaEffect}; +use super::nodes::{instances, node_object}; +use super::{Attribute, Context, JsArray, JsObject, JsResult, JsValue, NativeFunction, ObjectInitializer, arg, function, js_string, s, text}; + +pub(super) fn list_object(ctx: &mut Context, items: &[usize]) -> JsResult { + let vals: Vec = items.iter().map(|i| JsValue::from(*i as f64)).collect(); + let arr = JsArray::from_iter(vals, ctx); + let arr_obj: JsObject = arr.into(); + fn item(_: &JsValue, args: &[JsValue], arr: &JsObject, ctx: &mut Context) -> JsResult { + let i = arg(args, 0).to_number(ctx)?; + if !i.is_finite() || i < 0.0 { + return Ok(JsValue::null()); + } + let v = JsArray::from_object(arr.clone())?.get(i as u64, ctx)?; + if v.is_undefined() { + return Ok(JsValue::null()); + } + let idx = v.to_number(ctx)? as usize; + node_object(ctx, idx) + } + let item_fn = function(ctx, NativeFunction::from_copy_closure_with_captures(item, arr_obj)); + Ok(ObjectInitializer::new(ctx) + .property(js_string!("length"), JsValue::from(items.len() as f64), Attribute::READONLY) + .property(js_string!("item"), item_fn, Attribute::all()) + .build() + .into()) +} + +/// The instance manager of the subform named `name` under `parent`. +pub(super) fn instance_manager(ctx: &mut Context, parent: usize, name: &str) -> JsResult { + let (count, min, max) = { + let h = host(ctx)?.borrow(); + let live = instances(&h, parent, name); + let first = live.first().and_then(|i| h.nodes.get(*i)); + (live.len(), first.map_or(0, |n| n.occur_min), first.and_then(|n| n.occur_max)) + }; + let key = ObjectInitializer::new(ctx) + .property(js_string!("parent"), JsValue::from(parent as f64), Attribute::READONLY) + .property(js_string!("name"), s(name), Attribute::READONLY) + .build(); + fn target_of(key: &JsObject, ctx: &mut Context) -> JsResult<(usize, String)> { + let p = key.get(js_string!("parent"), ctx)?.to_number(ctx)? as usize; + let n = text(&key.get(js_string!("name"), ctx)?, ctx)?; + Ok((p, n)) + } + fn add_instance(_: &JsValue, _: &[JsValue], key: &JsObject, ctx: &mut Context) -> JsResult { + let (p, n) = target_of(key, ctx)?; + match add_instance_of(ctx, p, &n)? { + Some(i) => node_object(ctx, i), + None => Ok(JsValue::null()), + } + } + fn insert_instance(this: &JsValue, args: &[JsValue], key: &JsObject, ctx: &mut Context) -> JsResult { + add_instance(this, args, key, ctx) + } + fn remove_instance(_: &JsValue, args: &[JsValue], key: &JsObject, ctx: &mut Context) -> JsResult { + let (p, n) = target_of(key, ctx)?; + let i = arg(args, 0).to_number(ctx)?; + if i.is_finite() && i >= 0.0 { + remove_instance_of(ctx, p, &n, i as usize)?; + } + Ok(JsValue::undefined()) + } + fn set_instances(_: &JsValue, args: &[JsValue], key: &JsObject, ctx: &mut Context) -> JsResult { + let (p, n) = target_of(key, ctx)?; + let want = arg(args, 0).to_number(ctx)?; + if !want.is_finite() || want < 0.0 { + return Ok(JsValue::undefined()); + } + host(ctx)?.borrow_mut().set_instances(p, &n, want as usize); + Ok(JsValue::undefined()) + } + fn move_instance(_: &JsValue, _: &[JsValue], _: &JsObject, _: &mut Context) -> JsResult { + Ok(JsValue::undefined()) + } + fn count_get(_: &JsValue, _: &[JsValue], key: &JsObject, ctx: &mut Context) -> JsResult { + let (p, n) = target_of(key, ctx)?; + let c = instances(&host(ctx)?.borrow(), p, &n).len(); + Ok(JsValue::from(c as f64)) + } + fn count_set(_: &JsValue, args: &[JsValue], key: &JsObject, ctx: &mut Context) -> JsResult { + set_instances(&JsValue::undefined(), args, key, ctx) + } + let count_g = function(ctx, NativeFunction::from_copy_closure_with_captures(count_get, key.clone())); + let count_s = function(ctx, NativeFunction::from_copy_closure_with_captures(count_set, key.clone())); + let add = function(ctx, NativeFunction::from_copy_closure_with_captures(add_instance, key.clone())); + let insert = function(ctx, NativeFunction::from_copy_closure_with_captures(insert_instance, key.clone())); + let remove = function(ctx, NativeFunction::from_copy_closure_with_captures(remove_instance, key.clone())); + let set = function(ctx, NativeFunction::from_copy_closure_with_captures(set_instances, key.clone())); + let mv = function(ctx, NativeFunction::from_copy_closure_with_captures(move_instance, key)); + let _ = count; + let o = ObjectInitializer::new(ctx) + .property(js_string!("min"), JsValue::from(min as f64), Attribute::READONLY) + .property(js_string!("max"), max.map_or(JsValue::from(-1.0), |m| JsValue::from(m as f64)), Attribute::READONLY) + .property(js_string!("addInstance"), add, Attribute::all()) + .property(js_string!("insertInstance"), insert, Attribute::all()) + .property(js_string!("removeInstance"), remove, Attribute::all()) + .property(js_string!("setInstances"), set, Attribute::all()) + .property(js_string!("moveInstance"), mv, Attribute::all()) + .property(js_string!("className"), s("instanceManager"), Attribute::READONLY) + .build(); + use boa_engine::property::PropertyDescriptor; + o.define_property_or_throw(js_string!("count"), PropertyDescriptor::builder().get(count_g).set(count_s).configurable(true), ctx)?; + Ok(o.into()) +} + +/// Re-derive the SOM of `idx` and everything under it from its parent and index. +fn renumber(h: &mut XHost, idx: usize, depth: usize) { + if depth > MAX_DEPTH { + return; + } + let (parent_som, name, index, kids) = { + let Some(n) = h.nodes.get(idx) else { return }; + let parent_som = n.parent.and_then(|p| h.nodes.get(p)).map(|p| p.som.clone()).unwrap_or_default(); + (parent_som, n.name.clone(), n.index, n.children.clone()) + }; + let som = child_som(parent_som, &name, index); + if let Some(n) = h.nodes.get_mut(idx) { + n.som = som; + } + for k in kids { + renumber(h, k, depth + 1); + } +} + +/// The SOM of instance `index` of the node `name` under a parent at `parent_som`. +fn child_som(parent_som: String, name: &str, index: usize) -> String { + if name.starts_with('#') || name.is_empty() { + parent_som + } else if parent_som.is_empty() { + format!("{name}[{index}]") + } else { + format!("{parent_som}.{name}[{index}]") + } +} + +/// A copy of `src`'s subtree (values cleared) under `parent`, returning the copy's index. +fn clone_subtree(h: &mut XHost, src: usize, parent: Option, depth: usize) -> Option { + if depth > MAX_DEPTH || h.nodes.len() >= MAX_NODES { + return None; + } + let (template, kids) = { + let n = h.nodes.get(src)?; + ( + HNode { + name: n.name.clone(), + som: String::new(), + kind: n.kind, + value: String::new(), + numeric: n.numeric, + presence: n.presence.clone(), + access: n.access.clone(), + repeatable: n.repeatable, + occur_min: n.occur_min, + occur_max: n.occur_max, + index: n.index, + parent, + children: Vec::new(), + gone: false, + // The copy holds no value, so nothing is shown in its picture either. + formatted: None, + }, + n.children.clone(), + ) + }; + let idx = h.nodes.len(); + h.nodes.push(template); + let mut copied = Vec::new(); + for k in kids { + // Nested repeating subforms copy their first instance only. + let skip = h.nodes.get(k).is_some_and(|c| c.gone || c.repeatable && c.index > 0); + if skip { + continue; + } + if let Some(ci) = clone_subtree(h, k, Some(idx), depth + 1) { + copied.push(ci); + } + } + if let Some(n) = h.nodes.get_mut(idx) { + n.children = copied; + } + Some(idx) +} + +fn add_instance_of(ctx: &mut Context, parent: usize, name: &str) -> JsResult> { + let mut h = host(ctx)?.borrow_mut(); + Ok(h.add_instance(parent, name)) +} + +fn remove_instance_of(ctx: &mut Context, parent: usize, name: &str, i: usize) -> JsResult<()> { + let mut h = host(ctx)?.borrow_mut(); + h.remove_instance(parent, name, i); + Ok(()) +} + +impl XHost { + /// A new instance of the subform `name` under `parent`, after the last one; `None` when the + /// subform doesn't repeat, is at its maximum, or the run added its limit. + pub(crate) fn add_instance(&mut self, parent: usize, name: &str) -> Option { + let h = self; + let live = instances(h, parent, name); + let &first = live.first()?; + let (repeatable, max) = h.nodes.get(first).map(|n| (n.repeatable, n.occur_max)).unwrap_or((false, Some(1))); + if !repeatable || max.is_some_and(|m| live.len() >= m) || h.added >= MAX_ADDED || h.nodes.len() >= MAX_NODES { + return None; + } + // The copy's SOM, as `renumber` will give it, so its effect is reserved before the copy. + let name = h.nodes.get(first).map(|n| n.name.clone()).unwrap_or_default(); + let parent_som = h.nodes.get(parent).map(|p| p.som.clone()).unwrap_or_default(); + let som = child_som(parent_som, &name, live.len()); + if !h.reserve(&XfaEffect::AddInstance { som: String::new() }, som.len()) { + return None; + } + let new = clone_subtree(h, first, Some(parent), 0)?; + let last = live.last().copied().unwrap_or(first); + if let Some(n) = h.nodes.get_mut(new) { + n.index = live.len(); + } + // Keep instances together: right after the last one. + if let Some(p) = h.nodes.get_mut(parent) { + let pos = p.children.iter().position(|&c| c == last).map_or(p.children.len(), |i| i + 1); + p.children.insert(pos, new); + } + renumber(h, new, 0); + h.added += 1; + h.record(XfaEffect::AddInstance { som }); + Some(new) + } + + /// Add or remove instances of the subform `name` under `parent` until there are `want`. + /// Bounded: adding stops at the cap or the maximum, removing at the floor, and a step that + /// changes nothing ends the loop. + pub(crate) fn set_instances(&mut self, parent: usize, name: &str, want: usize) { + let want = want.min(MAX_ADDED); + for _ in 0..=MAX_ADDED { + let have = instances(self, parent, name).len(); + if have < want { + if self.add_instance(parent, name).is_none() { + break; + } + } else if have > want && have > 0 { + self.remove_instance(parent, name, have - 1); + if instances(self, parent, name).len() == have { + break; + } + } else { + break; + } + } + } + + /// Remove instance `i` of the subform `name` under `parent` (never below the minimum, or + /// below one). + pub(crate) fn remove_instance(&mut self, parent: usize, name: &str, i: usize) { + let h = self; + let live = instances(h, parent, name); + let Some(&gone) = live.get(i) else { return }; + let min = h.nodes.get(gone).map_or(0, |n| n.occur_min).max(1); + if live.len() <= min { + return; + } + let som = h.nodes.get(gone).map(|n| n.som.clone()).unwrap_or_default(); + if !h.reserve(&XfaEffect::RemoveInstance { som: String::new() }, som.len()) { + return; + } + if let Some(n) = h.nodes.get_mut(gone) { + n.gone = true; + } + // Later instances move down one. + for (k, &inst) in live.iter().enumerate().skip(i + 1) { + if let Some(n) = h.nodes.get_mut(inst) { + n.index = k - 1; + } + renumber(h, inst, 0); + } + h.record(XfaEffect::RemoveInstance { som }); + } +} diff --git a/crates/js/src/xfa/model.rs b/crates/js/src/xfa/model.rs new file mode 100644 index 0000000000..04ec0f8349 --- /dev/null +++ b/crates/js/src/xfa/model.rs @@ -0,0 +1,230 @@ +use super::{HashSet, Message}; + +/// What kind of template object a node is. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum XfaKind { + /// `xfa.form` itself. + Form, + Subform, + Field, + ExclGroup, + Draw, + Area, +} + +impl XfaKind { + pub(crate) fn class_name(self) -> &'static str { + match self { + XfaKind::Form => "form", + XfaKind::Subform => "subform", + XfaKind::Field => "field", + XfaKind::ExclGroup => "exclGroup", + XfaKind::Draw => "draw", + XfaKind::Area => "area", + } + } +} + +/// One object of the form, as the caller snapshots it. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct XfaNode { + pub name: String, + /// The SOM expression naming this instance (`form[0].head[0].familyName[0]`). + pub som: String, + pub kind: Option, + /// Fields: the raw value (ISO dates, on values for buttons); empty when null. + pub value: String, + /// Numeric fields give numbers to scripts. + pub numeric: bool, + /// `visible`, `invisible`, `hidden` or `inactive`. + pub presence: String, + /// `open`, `readOnly`, `protected` or `nonInteractive`. + pub access: String, + /// Subforms: may have several instances (`occur max` other than 1). + pub repeatable: bool, + pub occur_min: usize, + pub occur_max: Option, + /// This instance's index among its same-named siblings. + pub index: usize, + pub children: Vec, + /// Fields: the value shown in the field's display picture, what `formattedValue` reads; + /// `None` when the picture doesn't change it (the raw value is read then). + pub formatted: Option, +} + +/// The event a script runs for. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct XfaEvent { + /// `click`, `calculate`, `validate`, `initialize`, `change`, `exit`, … + pub activity: String, + /// The SOM of `this`. + pub target: String, + pub new_text: String, + pub prev_text: String, +} + +/// About the document. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct XfaDoc { + pub file_name: String, + /// The current page, 0-based. + pub page: usize, + pub page_count: usize, +} + +/// Something a script changed or asked for. +#[derive(Clone, Debug, PartialEq)] +pub enum XfaEffect { + SetValue { + som: String, + value: String, + }, + SetPresence { + som: String, + presence: String, + }, + SetAccess { + som: String, + access: String, + }, + /// A new instance of a repeating subform, named by its own SOM (`…row[3]`). + AddInstance { + som: String, + }, + /// The instance at this SOM is removed. + RemoveInstance { + som: String, + }, + /// `xfa.host.messageBox(…)`, or `app.alert(…)` in an XFA form's script. + MessageBox(Message), + /// `xfa.host.resetData()`: the named fields, or every field when empty. + ResetData(Vec), + /// `app.execMenuItem("ShowHide…")`: show or hide a side panel. + ShowPanel(crate::Panel), + Print, + SaveAs, + LaunchUrl(String), + SetFocus(String), + Beep, + /// `xfa.form.recalculate(true)`. + Recalculate, + /// `xfa.form.remerge()`, `xfa.layout.relayout()`. + Relayout, +} + +/// What running a script did. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct XfaOutcome { + pub effects: Vec, + pub console: Vec, + /// The script's error, if it failed. + pub error: Option, + /// The script's completion value (a calculate script's result; a validate script's verdict). + pub result: Option, + /// The completion value as a boolean, when it was one. + pub result_bool: Option, + /// Limits the script ran into (too many effects, message boxes or console lines): what + /// was left out, for the caller to report. + pub notes: Vec, + /// The script ran past its time limit and was left running on its own thread (the engine + /// can't interrupt it): the caller should run no more of this form's scripts. + pub abandoned: bool, +} + +/// Most nodes a snapshot may hold. +pub(super) const MAX_NODES: usize = 200_000; + +/// Most instances a script may add in one run. +pub(crate) const MAX_ADDED: usize = 1_000; + +/// Deepest tree followed. +pub(super) const MAX_DEPTH: usize = 64; + +/// Most effects one run may produce (after values set again on the same object are merged). +pub const MAX_EFFECTS: usize = 10_000; + +/// Most message boxes one run may show. +pub const MAX_ALERTS: usize = 100; + +/// Most console lines one run may print. +pub const MAX_CONSOLE: usize = 1_000; + +/// Longest message or console line kept, in characters. +pub(crate) const MAX_LINE: usize = 4_096; + +/// Most bytes of text one run may hand back or write: messages, console lines, URLs, names +/// and the values it sets. Past it, more are left out and noted. +pub(crate) const MAX_PAYLOAD: usize = 32 << 20; + +/// Longest value a script may put in a field (characters); longer ones are cut and noted. +pub(super) const MAX_VALUE: usize = 65_536; + +/// `m` cut to [`MAX_LINE`] characters. +pub(crate) fn clip(m: String) -> String { + match m.char_indices().nth(MAX_LINE) { + Some((at, _)) => format!("{}…", m.get(..at).unwrap_or_default()), + None => m, + } +} + +/// `v` cut to [`MAX_VALUE`] characters, and whether it was cut. +pub(crate) fn clip_value(v: String) -> (String, bool) { + match v.char_indices().nth(MAX_VALUE) { + Some((at, _)) => (v.get(..at).unwrap_or_default().to_string(), true), + None => (v, false), + } +} + +/// The field names of a `resetData` list (comma-separated), each cut to [`MAX_LINE`] and +/// named once, at most as many as a snapshot holds nodes. +pub(crate) fn reset_names(list: &str) -> Vec { + let mut seen = HashSet::new(); + let mut out = Vec::new(); + for name in list.split(',').map(str::trim).filter(|s| !s.is_empty()) { + if out.len() >= MAX_NODES { + break; + } + let name = clip(name.to_string()); + if seen.insert(name.clone()) { + out.push(name); + } + } + out +} + +/// The (property, SOM) an effect setting a property of an object is merged by, see +/// [`XHost::set_at`]. +pub(super) fn effect_key(e: &XfaEffect) -> Option<(u8, String)> { + match e { + XfaEffect::SetValue { som, .. } => Some((0, som.clone())), + XfaEffect::SetPresence { som, .. } => Some((1, som.clone())), + XfaEffect::SetAccess { som, .. } => Some((2, som.clone())), + _ => None, + } +} + +/// Bytes of text `e` holds. +pub(super) fn payload(e: &XfaEffect) -> usize { + match e { + XfaEffect::SetValue { som, value: s } | XfaEffect::SetPresence { som, presence: s } | XfaEffect::SetAccess { som, access: s } => { + som.len().saturating_add(s.len()) + } + XfaEffect::AddInstance { som } | XfaEffect::RemoveInstance { som } => som.len(), + XfaEffect::MessageBox(m) => m.bytes(), + XfaEffect::LaunchUrl(s) | XfaEffect::SetFocus(s) => s.len(), + XfaEffect::ResetData(names) => names.iter().fold(0usize, |n, s| n.saturating_add(s.len()).saturating_add(std::mem::size_of::())), + XfaEffect::Print | XfaEffect::SaveAs | XfaEffect::Beep | XfaEffect::Recalculate | XfaEffect::Relayout | XfaEffect::ShowPanel(_) => 0, + } +} + +/// A script's completion value (what a calculate script puts in its field) cut to +/// [`MAX_VALUE`] characters, as values a script sets are, with a note when it was. +pub(crate) fn clip_result(out: &mut XfaOutcome) { + if let Some(r) = out.result.take() { + let (r, cut) = clip_value(r); + if cut { + out.notes.push(format!("the script's result was longer than {MAX_VALUE} characters; it was cut")); + } + out.result = Some(r); + } +} diff --git a/crates/js/src/xfa/nodes.rs b/crates/js/src/xfa/nodes.rs new file mode 100644 index 0000000000..75943352e6 --- /dev/null +++ b/crates/js/src/xfa/nodes.rs @@ -0,0 +1,346 @@ +use super::globals::{recalculate, relayout}; +use super::host::{HNode, XHost, host}; +use super::instances::{instance_manager, list_object}; +use super::model::{MAX_DEPTH, XfaEffect}; +use super::{ + Attribute, Context, JsObject, JsProxy, JsResult, JsString, JsValue, NativeFunction, ObjectInitializer, arg, as_number, error, function, + js_string, s, text, +}; + +// ── node objects ──────────────────────────────────────────────────────────────────────────── + +const IDX: &str = "__xfaNode"; + +pub(super) fn node_index(target: &JsValue, ctx: &mut Context) -> JsResult { + let o = target.as_object().ok_or_else(|| error("not an XFA node"))?; + let v = o.get(JsString::from(IDX), ctx)?; + let n = v.to_number(ctx)?; + if !n.is_finite() || n < 0.0 { + return Err(error("not an XFA node")); + } + Ok(n as usize) +} + +/// A Proxy standing for node `idx`. +pub(super) fn node_object(ctx: &mut Context, idx: usize) -> JsResult { + let target = ObjectInitializer::new(ctx).property(JsString::from(IDX), JsValue::from(idx as f64), Attribute::READONLY).build(); + let proxy = JsProxy::builder(target).get(node_get).set(node_set).has(node_has).build(ctx)?; + Ok(proxy.into()) +} + +/// An object that takes any property read or write without complaint (`this.border.fill…`, +/// `this.fillColor = …`): what a script sets there changes nothing here. +pub(super) fn sink(ctx: &mut Context) -> JsResult { + let target = ObjectInitializer::new(ctx).build(); + let proxy = JsProxy::builder(target).get(sink_get).set(sink_set).build(ctx)?; + Ok(proxy.into()) +} + +fn sink_get(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let key = arg(args, 1); + if key.as_symbol().is_some() { + return Ok(JsValue::undefined()); + } + match text(&key, ctx)?.as_str() { + "value" | "toString" | "valueOf" => Ok(s("")), + _ => sink(ctx), + } +} + +fn sink_set(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { + Ok(JsValue::from(true)) +} + +fn node_has(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { + Ok(JsValue::from(true)) +} + +fn value_js(h: &HNode) -> JsValue { + if h.value.is_empty() { + return JsValue::null(); + } + if h.numeric + && let Some(n) = as_number(&h.value) + { + return JsValue::from(n); + } + s(&h.value) +} + +/// `(idx, name)` of the child of `parent` named `name`, first instance. +pub(crate) fn child_named(h: &XHost, parent: usize, name: &str) -> Option { + let p = h.nodes.get(parent)?; + p.children.iter().copied().find(|&c| h.nodes.get(c).is_some_and(|n| !n.gone && n.name == name)) +} + +/// Every live instance named `name` under `parent`, in order. +pub(crate) fn instances(h: &XHost, parent: usize, name: &str) -> Vec { + h.nodes + .get(parent) + .map(|p| p.children.iter().copied().filter(|&c| h.nodes.get(c).is_some_and(|n| !n.gone && n.name == name)).collect()) + .unwrap_or_default() +} + +/// Resolve a SOM expression from node `from`: `$`, `$form`, `$record`, `$host`, `xfa.form…`, +/// `parent`, `..name` (descendant search), `name[n]`, `name[*]`, `_name`. +pub(crate) fn resolve_som(h: &XHost, from: usize, expr: &str) -> Vec { + let expr = expr.trim(); + if expr.is_empty() { + return vec![from]; + } + let root = 0usize; + let mut cur: Vec = vec![from]; + let mut rest = expr; + let starts = + [("$form", root), ("xfa.form", root), ("$record", root), ("xfa.datasets.data", root), ("xfa.record", root), ("$", from), ("this", from)]; + for (prefix, at) in starts { + if let Some(r) = rest.strip_prefix(prefix) + && (r.is_empty() || r.starts_with('.') || r.starts_with('[')) + { + cur = vec![at]; + rest = r; + break; + } + } + let mut depth = 0; + while !rest.is_empty() && depth < MAX_DEPTH { + depth += 1; + let descend = rest.starts_with(".."); + rest = rest.trim_start_matches('.'); + let end = rest.find(['.', '[']).unwrap_or(rest.len()); + let (name, after) = rest.split_at(end); + let name = name.trim(); + let (index, after) = match after.strip_prefix('[') { + Some(a) => { + let close = a.find(']').unwrap_or(a.len()); + let idx = a.get(..close).map(str::trim).unwrap_or(""); + (Some(idx.to_string()), a.get(close + 1..).unwrap_or("")) + } + None => (None, after), + }; + rest = after; + let mut next = Vec::new(); + for &c in &cur { + match name { + "" => {} + "parent" => { + if let Some(p) = h.nodes.get(c).and_then(|n| n.parent) { + next.push(p); + } + } + _ => { + let mut found: Vec = if descend { descendants_named(h, c, name, 0) } else { instances(h, c, name) }; + // A relative name a node has no child for is looked for among its siblings + // and in each enclosing container, nearest first (so a field's script can + // say `resolveNode("otherField")`). + if found.is_empty() && depth == 1 && !descend { + let mut at = h.nodes.get(c).and_then(|n| n.parent); + let mut guard = 0; + while let Some(p) = at + && guard < MAX_DEPTH + { + found = instances(h, p, name); + if !found.is_empty() { + break; + } + at = h.nodes.get(p).and_then(|n| n.parent); + guard += 1; + } + } + match index.as_deref() { + Some("*") => next.extend(found), + Some(i) => { + if let Some(k) = i.parse::().ok().and_then(|i| found.get(i)) { + next.push(*k); + } + } + None => next.extend(found.first()), + } + } + } + } + // Each node once: `row[*].parent.row[*]` would otherwise multiply the list by the + // number of rows at every step. + let mut seen = std::collections::HashSet::with_capacity(next.len()); + next.retain(|i| seen.insert(*i)); + cur = next; + if cur.is_empty() { + break; + } + } + cur +} + +/// The live descendants of node `from` named `name` (`from..name` in a SOM path), in document +/// order, to [`MAX_DEPTH`]. +pub(crate) fn descendants_named(h: &XHost, from: usize, name: &str, depth: usize) -> Vec { + let mut out = Vec::new(); + if depth > MAX_DEPTH { + return out; + } + let Some(n) = h.nodes.get(from) else { return out }; + for &c in &n.children { + if let Some(k) = h.nodes.get(c) + && !k.gone + { + if k.name == name { + out.push(c); + } else { + out.extend(descendants_named(h, c, name, depth + 1)); + } + } + } + out +} + +/// A property read on a form node's Proxy: its value (and `formattedValue` as its picture shows +/// it), attributes, relatives, methods, instance managers and children by name. +fn node_get(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let idx = node_index(&arg(args, 0), ctx)?; + let key = arg(args, 1); + if key.as_symbol().is_some() { + return Ok(JsValue::undefined()); + } + let key = text(&key, ctx)?; + let snapshot = { + let h = host(ctx)?.borrow(); + let Some(n) = h.nodes.get(idx) else { return Ok(JsValue::undefined()) }; + (n.name.clone(), n.som.clone(), n.kind, n.value.clone(), n.presence.clone(), n.access.clone(), n.index, n.parent, n.children.clone()) + }; + let (name, som, kind, value, presence, access, index, parent, children) = snapshot; + Ok(match key.as_str() { + IDX => JsValue::from(idx as f64), + "rawValue" | "value" => host(ctx)?.borrow().nodes.get(idx).map_or(JsValue::null(), value_js), + "formattedValue" => s(&host(ctx)?.borrow().nodes.get(idx).and_then(|n| n.formatted.clone()).unwrap_or(value)), + "name" => s(&name), + "somExpression" => s(&som), + "className" => s(kind.class_name()), + "index" | "instanceIndex" => JsValue::from(index as f64), + "presence" => s(&presence), + "access" => s(&access), + "isNull" => JsValue::from(value.is_empty()), + "mandatory" => s("disabled"), + "parent" => match parent { + Some(p) => node_object(ctx, p)?, + None => JsValue::null(), + }, + "nodes" => { + let live: Vec = { + let h = host(ctx)?.borrow(); + children.iter().copied().filter(|&c| h.nodes.get(c).is_some_and(|n| !n.gone)).collect() + }; + list_object(ctx, &live)? + } + "all" => { + let live = match parent { + Some(p) => instances(&host(ctx)?.borrow(), p, &name), + None => vec![idx], + }; + list_object(ctx, &live)? + } + "instanceManager" => match parent { + Some(p) => instance_manager(ctx, p, &name)?, + None => JsValue::undefined(), + }, + "resolveNode" => resolver(ctx, idx, false)?, + "resolveNodes" => resolver(ctx, idx, true)?, + "recalculate" => function(ctx, NativeFunction::from_fn_ptr(recalculate)).into(), + "remerge" => function(ctx, NativeFunction::from_fn_ptr(relayout)).into(), + "getAttribute" + | "setAttribute" + | "execEvent" + | "execInitialize" + | "execCalculate" + | "execValidate" + | "getElement" + | "setElement" + | "isPropertySpecified" + | "loadXML" + | "saveXML" + | "clone" => function(ctx, NativeFunction::from_fn_ptr(noop)).into(), + "toString" | "valueOf" | "toJSON" => function(ctx, NativeFunction::from_fn_ptr(node_to_string)).into(), + "length" => JsValue::undefined(), + "border" | "ui" | "font" | "caption" | "fillColor" | "borderColor" | "fontColor" | "margin" | "para" | "assist" | "validate" | "format" + | "items" | "bind" | "keep" | "h" | "w" | "x" | "y" | "minH" | "maxH" | "minW" | "maxW" | "layout" | "model" | "oneOfChild" | "locale" + | "relevant" | "colSpan" | "dataNode" | "defaultValue" | "editValue" | "selectedIndex" | "event" | "calculate" | "traversal" | "extras" + | "desc" => sink(ctx)?, + other => { + if let Some(child) = other.strip_prefix('_') { + let exists = child_named(&host(ctx)?.borrow(), idx, child).is_some(); + return if exists { instance_manager(ctx, idx, child) } else { Ok(JsValue::undefined()) }; + } + let found = child_named(&host(ctx)?.borrow(), idx, other); + match found { + Some(c) => node_object(ctx, c)?, + None => JsValue::undefined(), + } + } + }) +} + +fn node_set(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let idx = node_index(&arg(args, 0), ctx)?; + let key = arg(args, 1); + if key.as_symbol().is_some() { + return Ok(JsValue::from(true)); + } + let key = text(&key, ctx)?; + let value = arg(args, 2); + let value = if value.is_null() || value.is_undefined() { String::new() } else { text(&value, ctx)? }; + let mut h = host(ctx)?.borrow_mut(); + let Some(n) = h.nodes.get(idx) else { return Ok(JsValue::from(true)) }; + let som = n.som.clone(); + match key.as_str() { + "rawValue" | "value" | "formattedValue" => { + // Not a field: the property doesn't exist, as in Acrobat, and nothing is recorded. + h.set_value(idx, value); + } + "presence" => { + let p = match value.as_str() { + "hidden" | "invisible" | "inactive" | "visible" => value.clone(), + _ => return Ok(JsValue::from(true)), + }; + h.set_state(idx, XfaEffect::SetPresence { som, presence: p }); + } + "access" => { + let a = match value.as_str() { + "open" | "readOnly" | "protected" | "nonInteractive" => value.clone(), + _ => return Ok(JsValue::from(true)), + }; + h.set_state(idx, XfaEffect::SetAccess { som, access: a }); + } + _ => {} + } + Ok(JsValue::from(true)) +} + +fn node_to_string(this: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { + let idx = node_index(this, ctx)?; + Ok(s(&host(ctx)?.borrow().nodes.get(idx).map(|n| n.value.clone()).unwrap_or_default())) +} + +pub(super) fn noop(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { + Ok(JsValue::undefined()) +} + +/// `resolveNode(som)` / `resolveNodes(som)` bound to node `from`. +pub(super) fn resolver(ctx: &mut Context, from: usize, many: bool) -> JsResult { + let key = ObjectInitializer::new(ctx) + .property(js_string!("from"), JsValue::from(from as f64), Attribute::READONLY) + .property(js_string!("many"), JsValue::from(many), Attribute::READONLY) + .build(); + fn resolve(_: &JsValue, args: &[JsValue], key: &JsObject, ctx: &mut Context) -> JsResult { + let from = key.get(js_string!("from"), ctx)?.to_number(ctx)? as usize; + let many = key.get(js_string!("many"), ctx)?.to_boolean(); + let expr = text(&arg(args, 0), ctx)?; + let found = resolve_som(&host(ctx)?.borrow(), from, &expr); + if many { + return list_object(ctx, &found); + } + match found.first() { + Some(i) => node_object(ctx, *i), + None => Ok(JsValue::null()), + } + } + Ok(function(ctx, NativeFunction::from_copy_closure_with_captures(resolve, key)).into()) +} diff --git a/crates/js/src/xfa/run.rs b/crates/js/src/xfa/run.rs new file mode 100644 index 0000000000..f951e02a54 --- /dev/null +++ b/crates/js/src/xfa/run.rs @@ -0,0 +1,170 @@ +use super::globals::install; +use super::host::{Shared, XHost}; +use super::model::{XfaDoc, XfaEvent, XfaNode, XfaOutcome, clip_result}; +use super::{Attribute, Context, JsResult, Limits, RefCell, Source, js_string, refuse, s, text}; + +/// Run `script` for `event` on the form `root` (the root subform), from node `event.target`. +pub fn run_xfa(script: &str, event: &XfaEvent, doc: &XfaDoc, root: &XfaNode, limits: Limits) -> XfaOutcome { + let failed = |why: String| XfaOutcome { error: Some(why), ..Default::default() }; + if let Some(why) = refuse(script) { + return failed(why); + } + #[cfg(not(target_arch = "wasm32"))] + { + std::thread::scope(|scope| { + let spawned = std::thread::Builder::new() + .name("pdfcraft-xfa-js".into()) + .stack_size(crate::SCRIPT_STACK) + .spawn_scoped(scope, || run_here(script, event, doc, root.clone(), limits, &|| {})); + match spawned { + Ok(thread) => thread.join().unwrap_or_else(|_| failed("the script stopped with an internal error".into())), + Err(e) => failed(format!("the script engine could not start: {e}")), + } + }) + } + #[cfg(target_arch = "wasm32")] + run_here(script, event, doc, root.clone(), limits, &|| {}) +} + +/// [`run_xfa`] with a time limit: a script still running `timeout` after the engine is ready +/// (loops inside nested function calls can run for hours within the engine's per-frame loop +/// limit) is left on its thread, which ends when the engine's own limits stop it, and an +/// outcome with `abandoned` set comes back at once. In the browser build there are no +/// threads: the script runs to its limits. +pub fn run_xfa_within(script: &str, event: &XfaEvent, doc: &XfaDoc, root: XfaNode, limits: Limits, timeout: std::time::Duration) -> XfaOutcome { + if let Some(why) = refuse(script) { + return XfaOutcome { error: Some(why), ..Default::default() }; + } + let (script, event, doc) = (script.to_string(), event.clone(), doc.clone()); + run_within("pdfcraft-xfa-js", timeout, move |started| run_here(&script, &event, &doc, root, limits, started)) +} + +/// Longest the script engine may take to be ready for a script (its thread and context, and +/// for the first script in a process the engine's own set-up) before the script is abandoned. +/// It doesn't count against the script's time: on a busy machine the start alone can take +/// longer than the tightest limit, and abandoning a script turns the form's scripts off. +pub(crate) const ENGINE_START_LIMIT: std::time::Duration = std::time::Duration::from_secs(10); + +/// Run `script` on its own thread (with the script stack) and wait at most `timeout` for its +/// outcome, counted from when it calls `started` (once the engine is ready); past that the +/// thread is abandoned and the outcome says so. On wasm there are no threads: the script runs +/// here. +pub(crate) fn run_within(name: &str, timeout: std::time::Duration, script: impl FnOnce(&dyn Fn()) -> XfaOutcome + Send + 'static) -> XfaOutcome { + run_within_limits(name, ENGINE_START_LIMIT, timeout, script) +} + +/// [`run_within`] with the engine's start-up limit given. +pub(crate) fn run_within_limits( + name: &str, + start_limit: std::time::Duration, + timeout: std::time::Duration, + script: impl FnOnce(&dyn Fn()) -> XfaOutcome + Send + 'static, +) -> XfaOutcome { + #[cfg(not(target_arch = "wasm32"))] + { + use std::sync::mpsc::RecvTimeoutError; + use std::time::Instant; + enum Step { + Started, + Done(XfaOutcome), + } + let failed = |why: String| XfaOutcome { error: Some(why), ..Default::default() }; + let abandoned = |why: String| XfaOutcome { error: Some(why), abandoned: true, ..Default::default() }; + let (tx, rx) = std::sync::mpsc::channel(); + let spawned = std::thread::Builder::new().name(name.into()).stack_size(crate::SCRIPT_STACK).spawn(move || { + // The receiver is gone when the caller stopped waiting: nothing to report then. + let started = tx.clone(); + let outcome = script(&move || { + let _ = started.send(Step::Started); + }); + let _ = tx.send(Step::Done(outcome)); + }); + if let Err(e) = spawned { + return failed(format!("the script engine could not start: {e}")); + } + // Wait for the engine within its start-up limit, then for the script within its own. + let (mut running, mut limit) = (false, start_limit); + let mut deadline = Instant::now().checked_add(limit); + loop { + let left = deadline.map_or(limit, |d| d.saturating_duration_since(Instant::now())); + match rx.recv_timeout(left) { + Ok(Step::Done(o)) => return o, + Ok(Step::Started) => { + // A second call changes nothing: the script's time has already begun. + if !running { + (running, limit) = (true, timeout); + deadline = Instant::now().checked_add(limit); + } + } + Err(RecvTimeoutError::Timeout) if running => { + return abandoned(format!("the script ran longer than {:.1} s and was abandoned", timeout.as_secs_f64())); + } + Err(RecvTimeoutError::Timeout) => { + return abandoned(format!("the script engine took longer than {:.0} s to start", start_limit.as_secs_f64())); + } + Err(RecvTimeoutError::Disconnected) => return failed("the script stopped with an internal error".into()), + } + } + } + #[cfg(target_arch = "wasm32")] + { + let _ = (name, start_limit, timeout); + script(&|| {}) + } +} + +/// The first script in a process sets the engine up (its built-ins, parser and compiler come +/// into use): a trivial script takes that on, so a form's script is never timed with it. +pub(crate) fn warm_up() { + static WARM: std::sync::Once = std::sync::Once::new(); + // `call_once_force`: a poisoned `Once` (a panic while warming up) only means warming up again. + WARM.call_once_force(|_| { + let mut ctx = Context::default(); + let _ = ctx.eval(Source::from_bytes(b"(function () { return eval('0'); }).call({})")); + }); +} + +/// `started` is called once the engine is ready: a time limit on the script runs from then. +pub(crate) fn run_here(script: &str, event: &XfaEvent, doc: &XfaDoc, root: XfaNode, limits: Limits, started: &dyn Fn()) -> XfaOutcome { + warm_up(); + let mut ctx = match Context::builder().host_hooks(std::rc::Rc::new(crate::CheckedCodeFromStrings)).build() { + Ok(ctx) => ctx, + Err(e) => return XfaOutcome { error: Some(format!("the script engine could not start: {e}")), ..Default::default() }, + }; + ctx.runtime_limits_mut().set_loop_iteration_limit(limits.loop_iterations); + ctx.runtime_limits_mut().set_recursion_limit(limits.recursion); + let host_state = XHost::new(root, &event.target, doc); + let current = host_state.current; + ctx.insert_data(RefCell::new(host_state)); + let mut out = XfaOutcome::default(); + let result = (|| -> JsResult<()> { + install(&mut ctx, event, doc, current)?; + // Ready for the script: its time starts now, the set-up above not charged to it. + started(); + ctx.register_global_property(js_string!("__xfa_src"), s(script), Attribute::CONFIGURABLE)?; + // Direct eval inside a function called on the node: `this` is the node and the + // completion value is the script's last expression (what a calculate script yields). + let r = ctx.eval(Source::from_bytes(b"(function () { return eval(__xfa_src); }).call(__xfa_this)"))?; + if !r.is_undefined() { + // Best effort: a value that can't be made text (a symbol, say) is no failure. + out.result = Some(text(&r, &mut ctx).unwrap_or_default()); + if r.is_boolean() { + out.result_bool = Some(r.to_boolean()); + } else if r.is_null() { + out.result = Some(String::new()); + } + } + Ok(()) + })(); + if let Err(e) = result { + out.error = Some(e.to_string()); + } + if let Some(h) = ctx.remove_data::() { + let h = h.into_inner(); + out.notes = h.notes(); + out.effects = h.effects; + out.console = h.console; + } + clip_result(&mut out); + out +} diff --git a/crates/measure/src/tests.rs b/crates/measure/src/tests.rs index 8d69a05cf0..5b277e5546 100644 --- a/crates/measure/src/tests.rs +++ b/crates/measure/src/tests.rs @@ -80,7 +80,7 @@ fn standard_annotations_captions_restyle_move_and_unknown_keys() { let (r, dict) = annot(&d, 0, 0).unwrap(); assert_eq!(dict.name(b"IT"), Some(&b"LineDimension"[..])); d.update_dict(r, |d| d.set(b"VendorKey".to_vec(), Object::Int(42))).unwrap(); - pdfcraft_annot::set_style(&mut d, 0, 0, Some([1.0, 0.0, 0.0]), None, Some(2.0), None, &meta).unwrap(); + pdfcraft_annot::set_style(&mut d, 0, 0, pdfcraft_annot::StyleChange::new(Some([1.0, 0.0, 0.0]), None, Some(2.0), None), &meta).unwrap(); let (_, dict) = annot(&d, 0, 0).unwrap(); assert_eq!(dict.int(b"VendorKey"), Some(42)); let ap = d.resolve(dict.get(b"AP").unwrap()); diff --git a/crates/ocr/Cargo.toml b/crates/ocr/Cargo.toml index 46bb3a1e0e..750e78b96d 100644 --- a/crates/ocr/Cargo.toml +++ b/crates/ocr/Cargo.toml @@ -13,5 +13,8 @@ workspace = true [dependencies] pdfcraft-fonts = { workspace = true } ocrs = { version = "0.13.1", default-features = false, features = ["rten"] } -rten = { version = "0.26.0", default-features = false, features = ["rten_format"] } +rten = { version = "0.26.0", default-features = false, features = ["onnx_format", "rten_format"] } thiserror.workspace = true + +[dev-dependencies] +tempfile = "3.27" diff --git a/crates/ocr/src/lib.rs b/crates/ocr/src/lib.rs index affa4ab163..7f58270e1a 100644 --- a/crates/ocr/src/lib.rs +++ b/crates/ocr/src/lib.rs @@ -15,8 +15,8 @@ use std::path::{Path, PathBuf}; pub use pdfcraft_fonts::helvetica_width; /// The model files, as named in ATTRIBUTION.toml. -pub const DETECTION_MODEL: &str = "text-detection.rten"; -pub const RECOGNITION_MODEL: &str = "text-recognition.rten"; +pub const DETECTION_MODEL: &str = "text-detection.onnx"; +pub const RECOGNITION_MODEL: &str = "text-recognition.onnx"; /// The recognition model's alphabet, as it was trained (ocrs-models' `DEFAULT_ALPHABET`): its /// output classes follow this order. The ocrs crate's own copy has an `E` where the training had @@ -48,10 +48,13 @@ pub struct Models { } impl Models { - /// The models in `dir`, if both files are there. + /// A complete model pair in `dir`, preferring current ONNX files over legacy RTEN files. + /// An incomplete update never mixes formats or hides an intact legacy pair. pub fn in_dir(dir: &Path) -> Option { - let m = Models { detection: dir.join(DETECTION_MODEL), recognition: dir.join(RECOGNITION_MODEL) }; - (m.detection.is_file() && m.recognition.is_file()).then_some(m) + [(DETECTION_MODEL, RECOGNITION_MODEL), ("text-detection.rten", "text-recognition.rten")].into_iter().find_map(|(detection, recognition)| { + let m = Models { detection: dir.join(detection), recognition: dir.join(recognition) }; + (m.detection.is_file() && m.recognition.is_file()).then_some(m) + }) } /// Look for the models: `$PDFCRAFT_MODELS`, then where the release packages install them @@ -321,6 +324,63 @@ mod tests { assert_eq!(w.up, [-10.0, 0.0]); } + /// The current ONNX pair wins when an installation also retains both legacy models. + #[test] + fn model_discovery_prefers_the_complete_onnx_pair() { + let dir = tempfile::tempdir().unwrap(); + for file in ["text-detection.onnx", "text-recognition.onnx"] { + std::fs::write(dir.path().join(file), b"model").unwrap(); + } + let expected = Some(Models { detection: dir.path().join("text-detection.onnx"), recognition: dir.path().join("text-recognition.onnx") }); + assert_eq!(Models::in_dir(dir.path()), expected, "a complete ONNX pair is sufficient"); + for file in ["text-detection.rten", "text-recognition.rten"] { + std::fs::write(dir.path().join(file), b"model").unwrap(); + } + assert_eq!(Models::in_dir(dir.path()), expected, "the complete ONNX pair takes precedence over legacy models"); + } + + /// An interrupted ONNX update still permits an intact legacy pair in the same directory. + #[test] + fn model_discovery_falls_back_to_legacy_when_the_onnx_pair_is_incomplete() { + for current in ["text-detection.onnx", "text-recognition.onnx"] { + let dir = tempfile::tempdir().unwrap(); + for file in [current, "text-detection.rten", "text-recognition.rten"] { + std::fs::write(dir.path().join(file), b"model").unwrap(); + } + assert_eq!( + Models::in_dir(dir.path()), + Some(Models { detection: dir.path().join("text-detection.rten"), recognition: dir.path().join("text-recognition.rten") }), + "{current} alone must not hide a complete legacy pair" + ); + } + } + + /// Detection and recognition must come from one complete format pair, never a mixed update. + #[test] + fn model_discovery_refuses_mixed_format_pairs() { + for pair in [["text-detection.onnx", "text-recognition.rten"], ["text-detection.rten", "text-recognition.onnx"]] { + let dir = tempfile::tempdir().unwrap(); + for file in pair { + std::fs::write(dir.path().join(file), b"model").unwrap(); + } + assert_eq!(Models::in_dir(dir.path()), None, "{pair:?} is not a complete model pair"); + } + } + + /// Existing source builds and PDFCRAFT_MODELS overrides retain their complete RTEN pair. + #[test] + fn model_discovery_keeps_legacy_only_installations() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!(Models::in_dir(dir.path()), None); + std::fs::write(dir.path().join("text-detection.rten"), b"model").unwrap(); + assert_eq!(Models::in_dir(dir.path()), None, "one legacy model is incomplete"); + std::fs::write(dir.path().join("text-recognition.rten"), b"model").unwrap(); + assert_eq!( + Models::in_dir(dir.path()), + Some(Models { detection: dir.path().join("text-detection.rten"), recognition: dir.path().join("text-recognition.rten") }) + ); + } + /// #103: every release package's layout puts the models where the installed app looks. /// Mirrors packaging/{windows/package.ps1,macos/package.sh,linux/package.sh,freebsd/package.sh}. #[test] diff --git a/crates/optimize/Cargo.toml b/crates/optimize/Cargo.toml index 456ab4c991..d1a759d468 100644 --- a/crates/optimize/Cargo.toml +++ b/crates/optimize/Cargo.toml @@ -24,3 +24,4 @@ zune-jpeg = "0.5" [dev-dependencies] pdfcraft-render = { workspace = true } +proptest.workspace = true diff --git a/crates/optimize/README.md b/crates/optimize/README.md index cbe37f6b7b..2a6c664e6a 100644 --- a/crates/optimize/README.md +++ b/crates/optimize/README.md @@ -11,7 +11,9 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going ``` - **Presets:** Standard (Reduce File Size's choices: images above 225 ppi to 150 ppi, JPEG - medium) and Mobile (above 144 ppi to 96 ppi, JPEG high), as in Acrobat's Settings menu. + medium) and Mobile (above 144 ppi to 96 ppi, JPEG high), as in Acrobat's Settings menu. In + both, black-and-white images are averaged down to 300 ppi above 450 ppi, and stored as CCITT + Group 4 whenever that is smaller. - **Merge image fragments** (`fragments`, on by default): some PDF printers draw a page picture as thousands of images one pixel row high. Runs of upright 8-bit grey or RGB images in one colour space, sharing their left and right edges and stacked edge to edge (at least four, @@ -26,18 +28,92 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going If a use can't be measured (forms nested deeper, or drawn too many times), no image is resampled. Colour and grayscale images above a threshold are resampled with a bicubic (Catmull-Rom) filter to a target resolution and - stored as JPEG (Acrobat's quality steps) or ZIP; soft masks are resampled with their image. - An image is replaced only if the result is smaller. Left alone: CMYK and other colour spaces, - image masks, decode arrays, more than 8 bits, JPEG 2000, JBIG2 and CCITT. A JPEG is read with + stored as JPEG (Acrobat's quality steps) or ZIP; soft masks are resampled with their image. A + stencil `/Mask` has its own resolution, so the picture changes under it and it stays as it is. + An image is replaced only if the result is smaller. Left alone by these colour and grey + settings: CMYK and other colour spaces, image masks, colour-key masks, decode arrays, more than 8 bits, JPEG 2000, + JBIG2 and CCITT (black-and-white images, CCITT and JBIG2 among them, are below). A JPEG is read with its colour transform (an Adobe marker's, else `/ColorTransform`, ISO 32000-2 §7.4.8), so one stored untransformed keeps its colours; one whose marker and `/ColorTransform` disagree is left - alone, since readers differ on which wins. + alone, since readers differ on which wins. Samples are read with `/DecodeParms` given by + reference too, the numbers inside included (`Document::with_direct_parms`), here and below: + without them a predictor's tag bytes would shift every row of an image written back. +- **Monochrome images** (`Settings::mono`, `MonoSettings::REDUCE` by default: to 300 ppi above + 450 ppi, CCITT Group 4). Black-and-white images are a 1-bit image with one colour component + (grey, indexed, a separation) or an image mask, stored with no filter, with general-purpose ones + or with CCITT fax or JBIG2 after any of those (decoded by `Document::decoded_image_within`, no further + than its rows can need; byte-aligned Group 3 with end-of-line codes but none before the first + row, not as T.4 sends them, is read as PdfCraft draws it, which at `/K 0` may be other rows than + were coded, and an image stored again keeps them). Packed to one bit first, unless a colour-key `/Mask` is in the image's + own values: eight-bit grey of only 0 and 255; eight-bit `DeviceRGB` of only black and white, + which becomes `DeviceGray` (not calibrated RGB or RGB with a profile, nor one drawn or listed + where `/DefaultGray` or `/DefaultRGB` is in force, which would then draw it otherwise, nor one + referred to from anywhere but the page and form resources the optimizer reads, where they may + be: an annotation's appearance or a pattern drawing it, directly or through resources or a form + it shares with a page, or another image's `/Alternates`; nor one whose soft mask has a + `/Matte`); and an indexed image of 2, 4 or 8 bits that uses only palette entries + 0 and 1, which keeps its palette, whatever its two colours. A decode array stays when it means + the same at one bit (grey's, or RGB's if the same for each component, as grey's), and an indexed + image's default one goes, as the default at one bit; otherwise the image is left alone. The grey + or colour settings take a packed image instead when they make it smaller still, its soft mask + counted (resampled with it there, kept as it is here), as they can a dithered scan by + downsampling it to JPEG. + - **Downsampling:** one whose effective resolution is above `above_ppi` is averaged down to + `target_ppi`: each new pixel takes the sample covering more of its area, and the dark one on a + tie, so a dark line half a new pixel wide stays: black in grey, the painted part of an image + mask (a 0, or a 1 with an inverting decode array), and the darker of an indexed image's two + palette entries. Only images whose dark sample is known so are averaged: `DeviceGray` (grey and + RGB packed to one bit among them), image masks, and indexed images over `DeviceGray` or + `DeviceRGB`, whose palette is read (Rec. 601 luma for RGB; a table of at most 4 KiB), with no + decode array or one of two different numbers, and not drawn or listed where `/DefaultGray` or + `/DefaultRGB` is or may be in force (an indexed image referred to from beyond the page and form + resources read, as an annotation's appearance, may be). Not one with a colour key, which can + hide the dark sample a tie would go to. Not a separation or a DeviceN, whose tint transform isn't read, calibrated or ICC + grey, or an indexed image over another base or whose two entries look alike. Not one with a soft mask, whose own size would have to + follow; not a halftone or a dither, most of whose new pixels would cover both black and white, + so that taking the sample covering more would turn its tones solid (a 50% screen black, a 25% + one white), nor one with a square of four new pixels that each would, as a small screened + picture on a white page has: ragged scanned text has such squares along its strokes too, and + is then left at its resolution. And not one wider than a fax row + (`pdfcraft_filters::MAX_CCITT_COLUMNS`), as the averaging keeps a counter for each column. On a + 600 ppi page of text that is averaged, Group 4 at 300 ppi is about half the size. + - **Compression:** CCITT Group 4 (`pdfcraft_filters::encode_g4`), or 1-bit Flate when that is + smaller, which only a resampled image or one packed from more bits may be: a 1-bit image that + is only stored again becomes Group 4 or stays. Group 4 alone is only decoded to be resampled, and so is JBIG2 alone when Group 4 is asked for; + Group 3, or Group 4 inside another filter such as `ASCII85Decode`, is stored again as Group 4 + alone when that is smaller. Damaged Group 3 with end-of-line codes is stored as the decoder + recovers it, a bad row picked up again at the next code, as it reports no damage there; without + them, damage stops the decoding and the image is left alone. JBIG2 inside another filter, or + alone when JBIG2 is asked for, is stored again as the compression asks when that is smaller (as JBIG2 symbols where + its own coding is a generic region, say); a symbol-coded scan, averaged, loses its exact + repeats and often comes out larger, and then stays as it is. A JBIG2 image stored again refers to no + `/JBIG2Globals`, and one whose page isn't its size (PdfCraft's + renderer draws the whole page into the image) is left alone. With Retain, a resampled image + keeps its kind of compression (CCITT, JBIG2, or Flate) and nothing else changes; an image of + more bits is then left to the grey or colour settings. Every change is kept only when it is + smaller as stored. + - Apart from the downsampling it is lossless: the samples keep their meaning, so the colour + space, decode array and `/Mask` stay, but for packed RGB, which becomes grey with one decode + pair. Group 4 wins on scans, whose ragged edges defeat Flate; on clean computer-drawn 1-bit + art Flate can be smaller. Not yet: inline images, an image's own `/SMask` or `/Mask` + image (only images drawn on a page are visited), and Group 4 for rows wider than 32,767 + samples. + + - Lossless JBIG2 uses bounded generic-region encoding or exact-copy symbol dictionaries when + smaller. No lossy symbol substitution is used. Export, redaction and this step read JBIG2 + images, with bounded shared globals. All of a run's images share one allowance of decoding + (`pdfcraft_cos::ImageBudget`), filters, decoders and pixels alike, JPEG samples and merged + pictures too: about + 1,000 scanned pages' worth, plus more for each byte of the file, enough to decode a stream + about 8 times at Flate's best. Images past it stay as they are, and `Report::images_refused` + counts those the image step read. Streams it only stores with Flate (below) aren't counted. - **Discard objects:** thumbnails, alternate images, document tags, print settings. - **Clean up:** Flate for streams with no filter; for streams with an outer ASCII85 or ASCIIHex layer, that layer is removed when the result is smaller (the inner filters keep their encoded bytes; streams with `/DecodeParms`, images and object streams keep their ASCII layer. LZW-compressed streams, content and images alike, are decoded and stored with Flate instead (Acrobat's "In - streams that use LZW encoding, use Flate instead"): a predictor carries over to Flate without + streams that use LZW encoding, use Flate instead"), from the run's allowance, past which they + stay LZW: a predictor carries over to Flate without `EarlyChange`, filters applied before the LZW one go with it and those after it keep their bytes. A stream whose LZW data doesn't decode cleanly is left as it is. Not yet: inline images. diff --git a/crates/optimize/src/fragments.rs b/crates/optimize/src/fragments.rs index c069de2f95..f74b091984 100644 --- a/crates/optimize/src/fragments.rs +++ b/crates/optimize/src/fragments.rs @@ -18,7 +18,7 @@ //! drops them. use pdfcraft_content::{Matrix, Op, parse, serialize_ops}; -use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, Stream}; +use pdfcraft_cos::{DecodeBudget, Dict, Document, ImageBudget, ObjRef, Object, Stream}; use crate::images::{components, content_bytes, decodable, page_resources, pixels, resample}; use crate::{OptimizeError, Report, Stage}; @@ -152,7 +152,7 @@ fn run_end(ops: &[Op], i: usize, count: usize) -> usize { } /// The run's pixels as one image, top row first: `(pixels, width, height)`. -fn merge(doc: &Document, run: &[Fragment], n: usize) -> Option<(Vec, u32, u32)> { +fn merge(doc: &Document, run: &[Fragment], n: usize, budget: &mut ImageBudget) -> Option<(Vec, u32, u32)> { let (top, bottom) = (run.first()?.rect[3], run.last()?.rect[1]); let width = run.iter().map(|f| f.width).max()?; // Pixel rows per unit of height: the finest fragment's. @@ -167,7 +167,12 @@ fn merge(doc: &Document, run: &[Fragment], n: usize) -> Option<(Vec, u32, u3 return None; } let row = (width as usize).checked_mul(n)?; - let mut out = vec![255u8; row.checked_mul(height as usize)?]; + // What this builds isn't what its strips cost to read: four small ones can ask for all of it. + let bytes = row.checked_mul(height as usize)?; + if !budget.pay_pixels(u64::try_from(bytes).ok()?) { + return None; + } + let mut out = vec![255u8; bytes]; for f in run { let r0 = (((top - f.rect[3]) * density).round().max(0.0) as u32).min(height); let r1 = (((top - f.rect[1]) * density).round().max(0.0) as u32).min(height); @@ -176,7 +181,7 @@ fn merge(doc: &Document, run: &[Fragment], n: usize) -> Option<(Vec, u32, u3 } let obj = doc.get(f.image); let Object::Stream(s) = &*obj else { return None }; - let px = pixels(doc, s, n)?; + let px = pixels(doc, s, n, budget)?; let px = if (f.width, f.height) == (width, r1 - r0) { px } else { resample(&px, n, f.width, f.height, width, r1 - r0)? }; out.get_mut(r0 as usize * row..r1 as usize * row)?.copy_from_slice(px.get(..(r1 - r0) as usize * row)?); } @@ -230,7 +235,13 @@ fn add_xobject(doc: &mut Document, page: ObjRef, name: &[u8], image: ObjRef) -> } /// Merge the fragment runs of one page; returns how many fragments were merged. -fn merge_page(doc: &mut Document, page: ObjRef, report: &mut Report, progress: &mut dyn FnMut(Stage) -> bool) -> Result { +fn merge_page( + doc: &mut Document, + page: ObjRef, + report: &mut Report, + progress: &mut dyn FnMut(Stage) -> bool, + images: &mut ImageBudget, +) -> Result { let Some(contents) = doc.get(page).as_dict().and_then(|d| d.get(b"Contents").cloned()) else { return Ok(0) }; // A page whose content isn't all known is left alone: rewriting it would drop the rest. let Some(data) = content_bytes(doc, &contents, &mut DecodeBudget::page()) else { return Ok(0) }; @@ -286,7 +297,7 @@ fn merge_page(doc: &mut Document, page: ObjRef, report: &mut Report, progress: & if !progress(Stage::Fragments) { return Err(OptimizeError::Cancelled); } - let Some((px, w, h)) = merge(doc, &run, n) else { + let Some((px, w, h)) = merge(doc, &run, n, images) else { out.extend(ops.get(i..j).unwrap_or_default().iter().cloned()); i = j; continue; @@ -320,9 +331,15 @@ fn merge_page(doc: &mut Document, page: ObjRef, report: &mut Report, progress: & Ok(merged) } -pub(crate) fn run(doc: &mut Document, pages: &[ObjRef], report: &mut Report, progress: &mut dyn FnMut(Stage) -> bool) -> Result<(), OptimizeError> { +pub(crate) fn run( + doc: &mut Document, + pages: &[ObjRef], + report: &mut Report, + progress: &mut dyn FnMut(Stage) -> bool, + budget: &mut ImageBudget, +) -> Result<(), OptimizeError> { for p in pages { - report.fragments_merged += merge_page(doc, *p, report, progress)?; + report.fragments_merged += merge_page(doc, *p, report, progress, budget)?; } Ok(()) } @@ -343,7 +360,49 @@ mod tests { Fragment { image, rect: [0.0, edge, 1.0, 1.0], width: 1 << 16, height: 1 }, Fragment { image, rect: [0.0, 0.0, 1.0, edge], width: 1 << 16, height: 1 }, ]; - assert!(merge(&Document::new_empty(), &run, 3).is_none()); + assert!(merge(&Document::new_empty(), &run, 3, &mut ImageBudget::with_work(u64::MAX)).is_none()); + } + + /// Merging reads the images the pass after it reads, from the same allowance: with nothing + /// left the strips stay as they are, and the budget shows the read it refused. + #[test] + fn merging_spends_the_runs_allowance() { + let mut doc = Document::new_empty(); + let mut image = Dict::new(); + for (k, v) in + [("Subtype", Object::name("Image")), ("Width", Object::Int(8)), ("Height", Object::Int(1)), ("BitsPerComponent", Object::Int(8))] + { + image.set(k.as_bytes().to_vec(), v); + } + image.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + let (mut xobjects, mut drawn, mut raw) = (Dict::new(), String::new(), 0); + for row in 0..8 { + let strip = Stream::flate(image.clone(), &[row * 30; 8]); + raw += strip.raw.len() as u64; + let r = doc.add(Object::Stream(strip)); + xobjects.set(format!("I{row}").into_bytes(), Object::Ref(r)); + drawn.push_str(&format!("q 80 0 0 10 0 {} cm /I{row} Do Q\n", 70 - 10 * i32::from(row))); + } + let content = doc.add(Object::Stream(Stream::flate(Dict::new(), drawn.as_bytes()))); + let mut resources = Dict::new(); + resources.set(b"XObject".to_vec(), Object::Dict(xobjects)); + let mut page = Dict::new(); + page.set(b"Type".to_vec(), Object::name("Page")); + page.set(b"Resources".to_vec(), Object::Dict(resources)); + page.set(b"Contents".to_vec(), Object::Ref(content)); + let page = doc.add(Object::Dict(page)); + let mut budget = ImageBudget::with_work(0); + assert_eq!(merge_page(&mut doc.clone(), page, &mut Report::default(), &mut |_| true, &mut budget).unwrap(), 0); + assert_eq!(budget.refused(), 1); + // Reading each strip, and building the picture of 8 rows of 8 they make. + let cost = raw + 8 * (8 + 8) + 8 * 8; + let mut budget = ImageBudget::with_work(u64::MAX); + assert_eq!(merge_page(&mut doc.clone(), page, &mut Report::default(), &mut |_| true, &mut budget).unwrap(), 8); + assert_eq!(u64::MAX - budget.work_left(), cost); + // Enough for the strips and not for the picture: nothing is built. + let mut budget = ImageBudget::with_work(cost - 1); + assert_eq!(merge_page(&mut doc, page, &mut Report::default(), &mut |_| true, &mut budget).unwrap(), 0); + assert_eq!(budget.refused(), 1); } /// Many images drawn on top of each other are not one picture; looking for runs among them reads @@ -370,7 +429,7 @@ mod tests { page.set(b"Resources".to_vec(), Object::Dict(resources)); let page = doc.add(Object::Dict(page)); GROUP_READS.with(|c| c.set(0)); - let merged = merge_page(&mut doc, page, &mut Report::default(), &mut |_| true); + let merged = merge_page(&mut doc, page, &mut Report::default(), &mut |_| true, &mut ImageBudget::with_work(u64::MAX)); assert_eq!(merged.ok(), Some(0)); let reads = GROUP_READS.with(|c| c.get()); assert!(reads <= 40 * 4 * groups, "{reads} group reads"); diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index b9761b85fc..efff394a6f 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -1,22 +1,30 @@ //! Image resampling and recompression. use std::collections::{HashMap, HashSet}; +use std::sync::Arc; use pdfcraft_content::{Matrix, Pieces}; -use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, Stream}; +use pdfcraft_cos::{DecodeBudget, Dict, Document, ImageBudget, ObjRef, Object, Stream}; -use crate::{Compression, ImageSettings, OptimizeError, Report, Settings, Stage}; +use crate::{Compression, ImageSettings, MonoCompression, MonoSettings, OptimizeError, Report, Settings, Stage}; /// Images with more pixels than this are left alone (memory). const MAX_PIXELS: u64 = 80_000_000; /// The resources of a page, inherited from the page tree if the page has none. pub(crate) fn page_resources(doc: &Document, page: ObjRef) -> Option { + held_page_resources(doc, page).map(|(resources, _)| resources) +} + +/// [`page_resources`], and the object holding them: the page or the page-tree node they are +/// written in (inherited from), or the resources dictionary when it is an object of its own. +fn held_page_resources(doc: &Document, page: ObjRef) -> Option<(Dict, ObjRef)> { let mut node = Some(page); for _ in 0..64 { - let d = doc.get(node?).as_dict()?.clone(); + let n = node?; + let d = doc.get(n).as_dict()?.clone(); if let Some(r) = d.get(b"Resources") { - return doc.resolve(r).as_dict().cloned(); + return Some((doc.resolve(r).as_dict().cloned()?, r.as_ref().unwrap_or(n))); } node = d.reference(b"Parent"); } @@ -41,9 +49,10 @@ struct Walk<'a> { doc: &'a Document, /// The forms being walked right now, outermost first: a form drawing one of these is a cycle. path: Vec, - /// Forms with their own resources already walked, by the linear part of their matrix. Drawing - /// one again at the same scale (only moved) can't change any image's resolution. - walked: HashSet<(ObjRef, [u64; 4])>, + /// Forms with their own resources already walked, by the linear part of their matrix and + /// whether default colour spaces were in force. Drawing one again at the same scale (only + /// moved) can't change any image's resolution. + walked: HashSet<(ObjRef, [u64; 4], bool)>, /// Forms walked at least once. seen: HashSet, rewalks: usize, @@ -52,14 +61,56 @@ struct Walk<'a> { /// False once a use may have been missed (depth or work limit). complete: bool, out: HashMap, + /// Images drawn or listed at least once where `/DefaultGray` or `/DefaultRGB` is in force, by + /// object number: a reference with another generation reaches the same image. + defaults: HashSet, + /// The references the walk followed to reach its images, and the pages and page-tree nodes + /// whose resources it read. + read: Read, +} + +/// Where a walk read a reference: under a name in an `/XObject` dictionary (an image or a form), +/// as resources' `/XObject` dictionary, or as the `/Resources` of a page, node or form. +#[derive(Clone, PartialEq, Eq, Hash)] +enum Entry { + Name(Vec), + XObject, + Resources, +} + +/// The references a walk followed, by object number: (the object referred to, the object holding +/// the reference, where in it), and the pages and page-tree nodes whose resources it read. +#[derive(Default)] +struct Read { + entries: HashSet<(u32, u32, Entry)>, + roots: HashSet, } impl Walk<'_> { /// Walk one content stream, recording each image's displayed resolution (pixels per inch, the - /// smaller of the two axes) and descending into form XObjects. - fn content(&mut self, data: &[u8], resources: &Dict, ctm: Matrix) { + /// smaller of the two axes) and descending into form XObjects. `holder` is the object holding + /// `resources`. `defaults`: an enclosing stream's resources remap a device colour space. + fn content(&mut self, data: &[u8], resources: &Dict, holder: ObjRef, ctm: Matrix, defaults: bool) { let doc = self.doc; + let defaults = defaults || remaps_device_spaces(doc, resources); let xobjects = resources.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default(); + let xholder = match resources.get(b"XObject").and_then(Object::as_ref) { + Some(x) => { + self.read.entries.insert((x.num, holder.num, Entry::XObject)); + x + } + None => holder, + }; + // Every entry counts as read, drawn here or not: content using these resources (a Type3 + // glyph without its own, say) draws it under the same defaults. + for (name, v) in xobjects.iter() { + if let Some(r) = v.as_ref() { + self.read.entries.insert((r.num, xholder.num, Entry::Name(name.to_vec()))); + if defaults { + self.defaults.insert(r.num); + } + } + } let mut stack = vec![ctm]; for op in pdfcraft_content::parse(data).ops { let top = stack.last().copied().unwrap_or(ctm); @@ -90,8 +141,11 @@ impl Walk<'_> { let e = self.out.entry(r).or_insert(f64::INFINITY); *e = e.min(ppi); } + if defaults { + self.defaults.insert(r.num); + } } - Some(b"Form") => self.form(r, s, resources, top), + Some(b"Form") => self.form(r, s, (resources, holder), top, defaults), _ => {} } } @@ -100,8 +154,37 @@ impl Walk<'_> { } } - /// Descend into form `r` drawn with `ctm` from a stream whose resources are `resources`. - fn form(&mut self, r: ObjRef, s: &Stream, resources: &Dict, ctm: Matrix) { + /// Read the resources of page `p` and of each page-tree node above it, each of them a root: a + /// node's resources are its inheriting pages' (every page is walked), or no page's. + fn resources_above(&mut self, p: ObjRef) { + let doc = self.doc; + let mut node = Some(p); + for _ in 0..64 { + let Some(n) = node else { return }; + // A node met before had its own nodes above it read then. + if !self.read.roots.insert(n.num) { + return; + } + let Some(d) = doc.get(n).as_dict().cloned() else { return }; + if let Some(r) = d.get(b"Resources") + && let Some(resources) = doc.resolve(r).as_dict().cloned() + { + let holder = match r.as_ref() { + Some(res) => { + self.read.entries.insert((res.num, n.num, Entry::Resources)); + res + } + None => n, + }; + self.content(&[], &resources, holder, Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]), false); + } + node = d.reference(b"Parent"); + } + } + + /// Descend into form `r` drawn with `ctm` from a stream whose resources are `resources`, with + /// the object holding them. + fn form(&mut self, r: ObjRef, s: &Stream, (resources, holder): (&Dict, ObjRef), ctm: Matrix, defaults: bool) { if self.path.contains(&r) { return; } @@ -116,7 +199,7 @@ impl Walk<'_> { // A form without resources uses its caller's, so the same form can draw other images. if own.is_some() { let [a, b, c, d, ..] = inner.0; - if !self.walked.insert((r, [a.to_bits(), b.to_bits(), c.to_bits(), d.to_bits()])) { + if !self.walked.insert((r, [a.to_bits(), b.to_bits(), c.to_bits(), d.to_bits()], defaults)) { return; } } @@ -136,17 +219,43 @@ impl Walk<'_> { return; }; self.path.push(r); - self.content(&data, own.as_ref().unwrap_or(resources), inner); + match &own { + Some(own) => { + let own_holder = match s.dict.get(b"Resources").and_then(Object::as_ref) { + Some(res) => { + self.read.entries.insert((res.num, r.num, Entry::Resources)); + res + } + None => r, + }; + self.content(&data, own, own_holder, inner, defaults); + } + None => self.content(&data, resources, holder, inner, defaults), + } self.path.pop(); } } +/// Whether `resources` remap a device colour space an image may be moved between, so that the +/// same samples would be drawn otherwise: `/DefaultGray` or `/DefaultRGB` (ISO 32000-2 §8.6.5.6). +fn remaps_device_spaces(doc: &Document, resources: &Dict) -> bool { + let spaces = resources.get(b"ColorSpace").map(|c| doc.resolve(c)); + spaces.as_deref().and_then(Object::as_dict).is_some_and(|c| c.contains(b"DefaultGray") || c.contains(b"DefaultRGB")) +} + /// The effective resolution (ppi) of every image drawn by `pages`: the smallest over its uses. /// /// If forms nest too deeply, are drawn too many times, or a page's content doesn't decode within /// its budget, every image is given `INFINITY` (never resampled), since its largest use may be /// among those missed. pub fn effective_resolutions(doc: &Document, pages: &[ObjRef]) -> HashMap { + walk_pages(doc, pages).0 +} + +/// [`effective_resolutions`], the images drawn or listed where `/DefaultGray` or `/DefaultRGB` is +/// in force, by object number (all of them when a use may have been missed), and the references +/// followed to reach them. +fn walk_pages(doc: &Document, pages: &[ObjRef]) -> (HashMap, HashSet, Read) { let mut walk = Walk { doc, path: Vec::new(), @@ -156,12 +265,15 @@ pub fn effective_resolutions(doc: &Document, pages: &[ObjRef]) -> HashMap HashMap bool { + let Object::Stream(s) = &*doc.get(r) else { return false }; + let cs = s.dict.get(b"ColorSpace").map(|cs| doc.resolve(cs)); + let codec = + s.filters().iter().any(|(f, _)| matches!(f, pdfcraft_filters::Filter::Dct | pdfcraft_filters::Filter::Jpx | pdfcraft_filters::Filter::Jbig2)); + s.dict.int(b"BitsPerComponent") == Some(8) + && !codec + && cs.as_deref().and_then(Object::as_name).is_some_and(|n| matches!(n, b"DeviceRGB" | b"RGB")) +} + +/// Whether image `r` is indexed: its palette says which of its samples is dark ([`dark_is_one`]), +/// which default colour spaces in force where it is drawn may change. +fn indexed(doc: &Document, r: ObjRef) -> bool { + let Object::Stream(s) = &*doc.get(r) else { return false }; + let cs = s.dict.get(b"ColorSpace").map(|cs| doc.resolve(cs)); + matches!(cs.as_deref(), Some(Object::Array(a)) if matches!(family(doc, a).as_deref().and_then(Object::as_name), Some(b"Indexed"))) +} + +/// The first entry of colour space array `a`, its family, read through a reference. +fn family(doc: &Document, a: &[Object]) -> Option> { + a.first().map(|f| doc.resolve(f)) +} + +/// Call `f` with every reference in `o`, a dictionary's or array's included, a stream's dictionary +/// too, but not those in objects referred to. +fn each_ref(o: &Object, mut f: impl FnMut(ObjRef)) { + // Direct objects nest finitely (the parser builds trees), so this ends. + let mut stack = vec![o]; + while let Some(o) = stack.pop() { + match o { + Object::Ref(r) => f(*r), + Object::Dict(d) => stack.extend(d.iter().map(|(_, v)| v)), + Object::Array(a) => stack.extend(a), + Object::Stream(s) => stack.extend(s.dict.iter().map(|(_, v)| v)), + _ => {} + } + } +} + +/// The images among `images` (by object number) that may be drawn beyond what the walk read: +/// some object the document uses refers to the image, or to an object on the way to it from a +/// page (an `/XObject` dictionary, resources, a form), other than as the page and form resources +/// the walk read do. So an annotation's appearance, a pattern, a Type3 glyph or a soft mask's +/// group drawing it, directly or through resources or a form it shares with a page, counts, as +/// other default colour spaces may be in force there, and so does any other reference (another +/// image's `/Alternates`, say). Objects nothing reaches from the trailer don't count. +fn used_elsewhere(doc: &Document, images: &HashSet, read: &Read) -> HashSet { + if images.is_empty() { + return HashSet::new(); + } + let targets: HashSet = read.entries.iter().map(|(target, ..)| *target).collect(); + // References to each object the walk followed, by the object holding them. + let mut held: HashMap> = HashMap::new(); + let mut queue: Vec = Vec::new(); + let mut visited = HashSet::new(); + for (_, v) in doc.trailer().iter() { + each_ref(v, |r| queue.push(r.num)); + } + while let Some(num) = queue.pop() { + if !visited.insert(num) { + continue; + } + let obj = doc.get(ObjRef::new(num, doc.generation(num))); + each_ref(&obj, |r| { + if targets.contains(&r.num) { + *held.entry(r.num).or_default().entry(num).or_default() += 1; + } + if !visited.contains(&r.num) { + queue.push(r.num); + } + }); + } + let mut seen: HashMap<(u32, u32), usize> = HashMap::new(); + for (target, holder, _) in &read.entries { + *seen.entry((*target, *holder)).or_default() += 1; + } + // Covered: every reference to it is one the walk followed, held by a page or node, or by an + // object covered in turn. Everything the walk reached starts covered, and whatever has a + // reference it didn't follow, or is held by an object taken out, is taken out, until nothing + // more is. Content beyond what the walk read has to refer to an object on its way to the + // image, which takes out everything after it; a cycle the walk read whole stays covered. + let mut holds: HashMap> = HashMap::new(); + for (target, holders) in &held { + for h in holders.keys() { + holds.entry(*h).or_default().push(*target); + } + } + let mut covered: HashSet = held.keys().copied().collect(); + let passes = |t: u32, covered: &HashSet| { + held.get(&t) + .is_some_and(|holders| holders.iter().all(|(h, n)| seen.get(&(t, *h)) == Some(n) && (read.roots.contains(h) || covered.contains(h)))) + }; + let mut check: Vec = covered.iter().copied().collect(); + while let Some(t) = check.pop() { + if covered.contains(&t) && !passes(t, &covered) { + covered.remove(&t); + check.extend(holds.get(&t).into_iter().flatten().copied()); + } + } + images.iter().copied().filter(|i| !covered.contains(i)).collect() } /// Colour components of a colour space PdfCraft resamples: gray (1) or RGB (3). @@ -247,7 +463,7 @@ fn is_dct(s: &Stream) -> Option { #[cfg(test)] thread_local! { - /// Pixels decoded from JPEG data, for the test that keeps a mismatched JPEG from decoding. + /// Pixels decoded, for the tests that keep an image from being decoded for nothing. static DECODED_PIXELS: std::cell::Cell = const { std::cell::Cell::new(0) }; } @@ -258,9 +474,12 @@ fn count_decoded(_n: u64) { DECODED_PIXELS.with(|c| c.set(c.get().saturating_add(_n))); } -/// 8-bit samples of an image (`n` components), decoded. `None` for a JPEG whose colour transform -/// readers may disagree about ([`ycc_encoded`]). -pub(crate) fn pixels(doc: &Document, s: &Stream, n: usize) -> Option> { +/// 8-bit samples of an image (`n` components), decoded, with `/DecodeParms` given by reference +/// too ([`Document::with_direct_parms`]): read without them, a predictor's tag bytes would shift +/// every row, and the image would be written back wrong. `None` for a JPEG whose colour transform +/// readers may disagree about ([`ycc_encoded`]), and for data that decodes past what the samples +/// can need ([`allowance`]). +pub(crate) fn pixels(doc: &Document, s: &Stream, n: usize, budget: &mut ImageBudget) -> Option> { let (w, h) = (u32::try_from(s.dict.int(b"Width")?).ok()?, u32::try_from(s.dict.int(b"Height")?).ok()?); if w == 0 || h == 0 || u64::from(w) * u64::from(h) > MAX_PIXELS { return None; @@ -271,15 +490,26 @@ pub(crate) fn pixels(doc: &Document, s: &Stream, n: usize) -> Option> { // never get here: the colour space check excludes them. The JPEG's own size must be the // dictionary's: `jpeg_samples` refuses another from its header, before any pixels. let ycc = n == 3 && ycc_encoded(doc, s)?; + if !budget.pay_jpeg(u64::try_from(bytes).ok()?, &s.raw) { + return None; + } let v = jpeg_samples(&s.raw, usize::try_from(w).ok()?, usize::try_from(h).ok()?, n, ycc)?; count_decoded(u64::from(w) * u64::from(h)); (v.len() == bytes).then_some(v) } else { - let v = s.decoded().ok()?; + let v = budget.decode_filters(doc, s, allowance(bytes, h)?).ok()?; Some(v.get(..bytes)?.to_vec()) } } +/// How far an image of `bytes` samples in `rows` rows is decoded: a filter stage may produce up +/// to three bytes for each of them and of a PNG predictor's bytes a row (ASCIIHex text under +/// Flate, its two digits a byte broken into lines or spaced). More can only be a decompression +/// bomb, and the image is left alone. +fn allowance(bytes: usize, rows: u32) -> Option { + bytes.checked_add(usize::try_from(rows).ok()?)?.checked_mul(3)?.checked_add(4096) +} + /// Whether the samples of a three-component JPEG are YCbCr, to be converted to RGB (ISO 32000-2 /// §7.4.8). An Adobe APP14 marker's transform flag says so, overriding the filter's /// /ColorTransform; without a marker /ColorTransform does, 1 (YCbCr) by default. @@ -405,13 +635,29 @@ fn jpeg_samples(data: &[u8], w: usize, h: usize, n: usize, ycc: bool) -> Option< } pub(crate) fn resample(px: &[u8], n: usize, w: u32, h: u32, nw: u32, nh: u32) -> Option> { - use image::imageops::{FilterType, resize}; Some(match n { - 1 => resize(&image::GrayImage::from_raw(w, h, px.to_vec())?, nw, nh, FilterType::CatmullRom).into_raw(), - _ => resize(&image::RgbImage::from_raw(w, h, px.to_vec())?, nw, nh, FilterType::CatmullRom).into_raw(), + 1 => scaled(&image::GrayImage::from_raw(w, h, px.to_vec())?, nw, nh).into_raw(), + _ => scaled(&image::RgbImage::from_raw(w, h, px.to_vec())?, nw, nh).into_raw(), }) } +/// `img` scaled to `w` × `h`. Scaling goes down first, through a buffer of `img`'s width by `h` at +/// 16 bytes a pixel, which a soft mask of other proportions than its image can make larger than +/// any machine: 1,000,000 × 1 resampled to 4 × 100,000 asks for 1.6 TB, and that aborts the +/// process. Such a one is scaled across first, which holds no more pixels than one of the two has. +/// Past a quarter over the larger of them, so that rounding leaves a picture of the same shape as +/// it was. +fn scaled + 'static>(img: &image::ImageBuffer>, w: u32, h: u32) -> image::ImageBuffer> { + use image::imageops::{FilterType, resize}; + let (iw, ih) = (u64::from(img.width()), u64::from(img.height())); + let largest = (iw * ih).max(u64::from(w) * u64::from(h)); + if (iw * u64::from(h)).saturating_mul(4) > largest.saturating_mul(5) { + resize(&resize(img, w, img.height(), FilterType::CatmullRom), w, h, FilterType::CatmullRom) + } else { + resize(img, w, h, FilterType::CatmullRom) + } +} + fn jpeg(px: &[u8], n: usize, w: u32, h: u32, quality: u8) -> Option> { let mut out = Vec::new(); let mut enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, quality.clamp(1, 100)); @@ -471,12 +717,27 @@ fn image_stream(old: &Dict, px: &[u8], n: usize, w: u32, h: u32, compression: Co } } +/// Whether a `/Mask` value is a stencil mask: an image that says `/ImageMask true`. +fn is_stencil(doc: &Document, mask: &Object) -> bool { + matches!(&*doc.resolve(mask), Object::Stream(s) if matches!(s.dict.get(b"ImageMask"), Some(Object::Bool(true)))) +} + /// One image: resample and/or recompress per `settings`; `ppi` is its effective resolution. /// Returns the new image and its new soft mask, if the result is smaller. -fn process(doc: &Document, s: &Stream, ppi: f64, settings: &ImageSettings, n: usize) -> Option<(Stream, Option<(ObjRef, Stream)>)> { +fn process( + doc: &Document, + s: &Stream, + ppi: f64, + settings: &ImageSettings, + n: usize, + budget: &mut ImageBudget, +) -> Option<(Stream, Option<(ObjRef, Stream)>)> { let d = &s.dict; + // A colour-key mask names sample values, which resampling and JPEG would change. A stencil mask + // is an image of its own, with its own resolution (ISO 32000-2 §8.9.6.3): the picture can change + // under it and its edges stay as they are. if d.get(b"Decode").is_some_and(|a| !is_identity_decode(doc, a, n)) - || d.contains(b"Mask") + || d.get(b"Mask").is_some_and(|m| !is_stencil(doc, m)) || d.name(b"ImageMask").is_some() || matches!(d.get(b"ImageMask"), Some(Object::Bool(true))) { @@ -505,7 +766,7 @@ fn process(doc: &Document, s: &Stream, ppi: f64, settings: &ImageSettings, n: us } else { (w, h) }; - let px = pixels(doc, s, n)?; + let px = pixels(doc, s, n, budget)?; let px = if (nw, nh) != (w, h) { resample(&px, n, w, h, nw, nh)? } else { px }; let new = image_stream(d, &px, n, nw, nh, settings.compression, was_dct)?; // A soft mask must keep the image's dimensions: resample it with the image (lossless). @@ -518,7 +779,7 @@ fn process(doc: &Document, s: &Stream, ppi: f64, settings: &ImageSettings, n: us return None; } let (mw, mh) = (u32::try_from(m.dict.int(b"Width")?).ok()?, u32::try_from(m.dict.int(b"Height")?).ok()?); - let mpx = pixels(doc, m, 1)?; + let mpx = pixels(doc, m, 1, budget)?; let mpx = resample(&mpx, 1, mw, mh, nw, nh)?; smask = Some((mr, image_stream(&m.dict, &mpx, 1, nw, nh, Compression::Flate, false)?)); } @@ -532,14 +793,440 @@ fn process(doc: &Document, s: &Stream, ppi: f64, settings: &ImageSettings, n: us (after < before).then_some((new, smask)) } +/// Whether colour space `cs` has one colour component, so a 1-bit image in it has one bit per +/// pixel: grey, a separation, an indexed space or a DeviceN of one colourant. Of names, only +/// DeviceGray counts, with `/G`, its abbreviation, which readers take as grey too: another name +/// is no colour space on its own (`/Indexed` and `/CalGray` need their parameters), so it can only +/// name a resource, which might be RGB. +fn one_component(doc: &Document, cs: &Object) -> bool { + let cs = doc.resolve(cs); + match &*cs { + Object::Name(n) => matches!(n.as_slice(), b"DeviceGray" | b"G"), + Object::Array(a) => match family(doc, a).as_deref().and_then(Object::as_name) { + Some(b"CalGray" | b"Separation" | b"Indexed" | b"I") => true, + Some(b"ICCBased") => a.get(1).is_some_and(|icc| matches!(&*doc.resolve(icc), Object::Stream(s) if s.dict.int(b"N") == Some(1))), + Some(b"DeviceN") => a.get(1).is_some_and(|names| doc.resolve(names).as_array().is_some_and(|n| n.len() == 1)), + _ => false, + }, + _ => false, + } +} + +/// A pixel's samples as a 1 or a 0, or `None` for a colour that is neither. +type Pixel = fn(&[u8]) -> Option; + +/// The samples of a black-and-white image, as rows of 1-bit samples each starting on a new byte, +/// with its width and height, and, when they were packed from more bits, its dictionary as a 1-bit +/// image. These count: +/// - one bit per pixel in one colour component, or an image mask, stored with no filter, with +/// general-purpose ones, or with CCITT fax or JBIG2 last ([`ImageBudget::decode`], no further +/// than its rows can need); +/// - with `pack`, and with no colour-key `/Mask` (its ranges are in the image's own values): +/// - eight-bit grey (one component, not indexed) whose every sample is 0 or 255. A decode array +/// maps 0 and 255 at eight bits as it maps 0 and 1 at one bit, so it stays; +/// - eight-bit `DeviceRGB` whose every pixel is black or white, which becomes `DeviceGray`. A +/// decode array the same for all three components becomes that one pair; another stops it. +/// Calibrated RGB and RGB with a profile are left out, as the profile could make them other +/// than black and white; so is one drawn where `/DefaultGray` or `/DefaultRGB` is or may be in +/// force (`defaults`), which would draw it otherwise as grey, and one whose soft mask has a +/// `/Matte` of three components; +/// - an indexed image of 2, 4 or 8 bits that uses only palette entries 0 and 1, which stay 0 and +/// 1 at one bit, whatever their colours, so the colour space stays. A decode array other than +/// the default stops it: in index units it would mean something else at one bit. +fn one_bit(doc: &Document, s: &Stream, pack: bool, defaults: bool, budget: &mut ImageBudget) -> Option<(Vec, u32, u32, Option)> { + let d = &s.dict; + // Through u32 first, as everywhere here, so the product can't overflow. + let (w, h) = (u32::try_from(d.int(b"Width")?).ok()?, u32::try_from(d.int(b"Height")?).ok()?); + if w == 0 || h == 0 || u64::from(w) * u64::from(h) > MAX_PIXELS { + return None; + } + let (wu, hu) = (usize::try_from(w).ok()?, usize::try_from(h).ok()?); + let stride = wu.div_ceil(8); + let bits = d.int(b"BitsPerComponent"); + let mask = matches!(d.get(b"ImageMask"), Some(Object::Bool(true))); + if (mask && bits.is_none_or(|b| b == 1)) || (bits == Some(1) && d.get(b"ColorSpace").is_some_and(|cs| one_component(doc, cs))) { + // No further than its rows can need: a fax image's code (the filters before its decoder + // produce it) takes at most about a byte a pixel and each row's end, as redaction allows. + let len = stride.checked_mul(hu)?; + let max = match s.filters().last() { + Some((pdfcraft_filters::Filter::CcittFax, _)) => wu.checked_mul(hu)?.checked_add(hu.checked_mul(4)?)?.checked_add(4096)?, + // A JBIG2 page, which has to be the image's size, is `len` bytes as rows of a bit. + _ => allowance(len, h)?, + }; + count_decoded(u64::from(w) * u64::from(h)); + let mut data = budget.decode(doc, s, max).ok()?; + if data.len() < len { + return None; + } + data.truncate(len); + return Some((data, w, h, None)); + } + let colour_key = d.get(b"Mask").is_some_and(|m| doc.resolve(m).as_array().is_some()); + if !pack || mask || colour_key { + return None; + } + let cs = d.get(b"ColorSpace")?; + // The decode array's numbers, each given directly or by reference, as integers or reals; `None` + // when it has something else. + let decode = match d.get(b"Decode").map(|v| doc.resolve(v)).as_deref() { + None => None, + Some(Object::Array(v)) => Some(v.iter().map(|e| doc.resolve(e).as_f64()).collect::>>()?), + Some(_) => return None, + }; + let mut packed = d.clone(); + packed.set(b"BitsPerComponent".to_vec(), Object::Int(1)); + // Components a pixel, bits a component, and a pixel's samples as 1 or 0. + let (n, bpc, white): (usize, usize, Pixel) = match &*doc.resolve(cs) { + Object::Array(a) if matches!(family(doc, a).as_deref().and_then(Object::as_name), Some(b"Indexed" | b"I")) => { + let bpc = bits.filter(|b| matches!(b, 2 | 4 | 8))?; + match decode.as_deref() { + None => {} + Some([d0, d1]) if *d0 == 0.0 && *d1 == f64::from((1u32 << bpc) - 1) => { + packed.remove(b"Decode"); + } + Some(_) => return None, + } + (1, usize::try_from(bpc).ok()?, |v| match v { + [0] => Some(false), + [1] => Some(true), + _ => None, + }) + } + Object::Name(name) if matches!(name.as_slice(), b"DeviceRGB" | b"RGB") && bits == Some(8) => { + let matte = |m: &Object| matches!(&*doc.resolve(m), Object::Stream(m) if m.dict.contains(b"Matte")); + if defaults || d.get(b"SMask").is_some_and(matte) { + return None; + } + match decode.as_deref() { + None => {} + Some(v @ [d0, d1, ..]) if v.len() == 6 && v.chunks(2).all(|pair| pair == [*d0, *d1]) => { + let number = |n: f64| if n.fract() == 0.0 { Object::Int(n as i64) } else { Object::Real(n) }; + packed.set(b"Decode".to_vec(), Object::Array(vec![number(*d0), number(*d1)])); + } + Some(_) => return None, + } + packed.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + (3, 8, |v| match v { + [0, 0, 0] => Some(false), + [255, 255, 255] => Some(true), + _ => None, + }) + } + _ if bits == Some(8) && one_component(doc, cs) => (1, 8, |v| match v { + [0] => Some(false), + [255] => Some(true), + _ => None, + }), + _ => return None, + }; + let row_len = wu.checked_mul(n)?.checked_mul(bpc)?.div_ceil(8); + // Through its parameters given by reference too: without a PNG predictor's tag bytes taken + // off, rows of 0 and 255 (or palette entries 0 and 1) would still pass below, each shifted. + let samples = budget.decode_filters(doc, s, allowance(row_len.checked_mul(hu)?, h)?).ok()?; + Some((packed_rows(&samples, row_len, wu, hu, (n, bpc, white))?, w, h, Some(packed))) +} + +/// `h` rows of `w` pixels, each row `row_len` bytes of `n` samples a pixel at `bpc` bits (8, or +/// a divisor of 8 with `n` 1), as rows of 1-bit samples each starting on a new byte: 1 where +/// `white` says the pixel is white. `None` when a pixel is neither, `samples` is short, or the +/// rows have another shape: no pixels in a row, other sample sizes, or too few bytes for their +/// pixels. +fn packed_rows(samples: &[u8], row_len: usize, w: usize, h: usize, (n, bpc, white): (usize, usize, Pixel)) -> Option> { + let stride = w.div_ceil(8); + let shape = (n > 0 && bpc == 8) || (n == 1 && matches!(bpc, 1 | 2 | 4)); + if stride == 0 || !shape || row_len < w.checked_mul(n)?.checked_mul(bpc)?.div_ceil(8) { + return None; + } + // From here no row is empty and x·n·bpc stays within one, and the output is no larger than + // the samples it is read from. + let samples = samples.get(..row_len.checked_mul(h)?)?; + let mut out = vec![0u8; stride.checked_mul(h)?]; + for (row, out_row) in samples.chunks(row_len).zip(out.chunks_mut(stride)) { + for x in 0..w { + let on = if bpc == 8 { + white(row.get(x * n..x * n + n)?)? + } else { + // Samples narrower than a byte, the first in its high bits; bpc divides 8. + let at = x * bpc; + white(&[(row.get(at / 8)? >> (8 - bpc - at % 8)) & ((1u8 << bpc) - 1)])? + }; + if on { + *out_row.get_mut(x / 8)? |= 0x80 >> (x % 8); + } + } + } + Some(out) +} + +/// Whether sample 1 is the dark one of the 1-bit image `d` describes, after its decode array +/// (`[0 1]` when it has none; another must be two different numbers): black in grey is a 0, and so +/// is the painted part of an image mask. An indexed image's samples pick palette entries (its +/// decode array maps them to entries, rounded and clipped to the table, ISO 32000-2 §8.9.5.2), and +/// the palette says which is darker ([`lightness`]). `None` for anything else: separations and +/// DeviceN, whose tint transforms aren't read, other colour spaces, and two entries alike. +fn dark_is_one(doc: &Document, d: &Dict) -> Option { + let number = |v: Option<&Object>| v.and_then(|v| doc.resolve(v).as_f64()).filter(|v| v.is_finite()); + let (d0, d1) = match d.get(b"Decode").map(|v| doc.resolve(v)).as_deref() { + Some(Object::Array(a)) if a.len() == 2 => number(a.first()).zip(number(a.get(1)))?, + None => (0.0, 1.0), + _ => return None, + }; + if d0 == d1 { + return None; + } + if matches!(d.get(b"ImageMask"), Some(Object::Bool(true))) { + return Some(d1 < d0); + } + // A colour key can hide the dark sample, and a tie going to it would lose a thin rule of the + // sample that shows. + if matches!(d.get(b"Mask").map(|m| doc.resolve(m)).as_deref(), Some(Object::Array(_))) { + return None; + } + match d.get(b"ColorSpace").map(|cs| doc.resolve(cs)).as_deref() { + Some(Object::Name(n)) if matches!(n.as_slice(), b"DeviceGray" | b"G") => Some(d1 < d0), + Some(Object::Array(a)) if matches!(family(doc, a).as_deref().and_then(Object::as_name), Some(b"Indexed")) => { + let hival = a.get(2).and_then(|v| match &*doc.resolve(v) { + Object::Int(h) if (0..=255).contains(h) => Some(*h), + _ => None, + })?; + // `hival` is 0 to 255, and the rounded value is clipped to it: the cast can't wrap. + let entry = |v: f64| v.round().clamp(0.0, hival as f64) as usize; + let (l0, l1) = (lightness(doc, a, entry(d0))?, lightness(doc, a, entry(d1))?); + (l0 != l1).then_some(l1 < l0) + } + _ => None, + } +} + +/// The most bytes a palette is read to (see [`lightness`]). +const MAX_PALETTE: usize = 4096; + +/// How light entry `i` of the palette of indexed colour space `a` (`[/Indexed base hival lookup]`) +/// is, from 0 (black) to 255,000 (white): its grey, or its luma (Rec. 601) over `DeviceRGB`. Only +/// those two bases count, where the palette's values are the colour: under `/DefaultGray` or +/// `/DefaultRGB` they aren't, and the caller asks for no polarity there; `None` for another base +/// (calibrated, CMYK, an ICC profile, a resource name), and for an entry the table doesn't hold. +fn lightness(doc: &Document, a: &[Object], i: usize) -> Option { + // A device space is its name, or that name alone in an array. + let base = doc.resolve(a.get(1)?); + let base = match &*base { + Object::Array(b) if b.len() == 1 => doc.resolve(b.first()?), + _ => base, + }; + let n = match base.as_name()? { + b"DeviceGray" => 1, + b"DeviceRGB" => 3, + _ => return None, + }; + // At most 256 entries of three components: more is no palette. Images share a table, and this + // is read for each of them, so a large one is refused before it's copied or decoded. + let table = match &*doc.resolve(a.get(3)?) { + Object::String(s) if s.bytes.len() <= MAX_PALETTE => s.bytes.clone(), + Object::Stream(s) if s.raw.len() <= 4 * MAX_PALETTE => doc.with_direct_parms(s).decoded_within(MAX_PALETTE).ok()?, + _ => return None, + }; + let c: Vec = table.get(i.checked_mul(n)?..i.checked_mul(n)?.checked_add(n)?)?.iter().map(|v| u32::from(*v)).collect(); + Some(match c.as_slice() { + [v] => v * 1000, + [r, g, b] => 299 * r + 587 * g + 114 * b, + _ => return None, + }) +} + +/// `w` × `h` 1-bit rows (each starting on a new byte) averaged down to `nw` × `nh`, no larger: +/// each new pixel takes the sample that covers more of its area, and the dark one when the two tie +/// (1 if `dark_is_one`, see [`dark_is_one`]), so a dark line half a new pixel wide stays. `None` +/// when the rows are short or the new size isn't smaller in both directions or empty. Also says +/// how many new pixels cover both samples. +#[cfg(test)] +pub(crate) fn downsample_bits(data: &[u8], w: u32, h: u32, nw: u32, nh: u32, dark_is_one: bool) -> Option<(Vec, u64)> { + downsample_bits_checked(data, w, h, nw, nh, dark_is_one).map(|(out, mixed, _)| (out, mixed)) +} + +fn downsample_bits_checked(data: &[u8], w: u32, h: u32, nw: u32, nh: u32, dark_is_one: bool) -> Option<(Vec, u64, bool)> { + if nw == 0 || nh == 0 || nw > w || nh > h { + return None; + } + let (stride, nstride) = (usize::try_from(w).ok()?.div_ceil(8), usize::try_from(nw).ok()?.div_ceil(8)); + let data = data.get(..stride.checked_mul(usize::try_from(h).ok()?)?)?; + let mut out = vec![0u8; nstride.checked_mul(usize::try_from(nh).ok()?)?]; + // In units of 1/nw of a source column, source column x spans [x·nw, (x+1)·nw) and new column i + // spans [i·w, (i+1)·w); rows likewise with h and nh. A new pixel's area is then w·h. + let (w, h, nw, nh) = (u64::from(w), u64::from(h), u64::from(nw), u64::from(nh)); + let overlap = |a0: u64, a1: u64, b0: u64, b1: u64| a1.min(b1).saturating_sub(a0.max(b0)); + // Per source column, the area its 1 samples cover in the new row. + let mut ones = vec![0u64; usize::try_from(w).ok()?]; + let mut mixed = 0u64; + let mut previous_mixed = vec![false; usize::try_from(nw).ok()?]; + let mut local_tone = false; + for j in 0..nh { + let mut left_mixed = false; + let mut above_left_mixed = false; + ones.fill(0); + let (y0, y1) = (j * h, (j + 1) * h); + for y in y0 / nh..y1.div_ceil(nh).min(h) { + let weight = overlap(y0, y1, y * nh, (y + 1) * nh); + let row = data.get(usize::try_from(y).ok()? * stride..)?; + for (x, sum) in ones.iter_mut().enumerate() { + if row.get(x / 8).is_some_and(|b| b & (0x80 >> (x % 8)) != 0) { + *sum += weight; + } + } + } + let out_row = out.get_mut(usize::try_from(j).ok()? * nstride..)?; + for i in 0..nw { + let (x0, x1) = (i * w, (i + 1) * w); + let covered: u64 = (x0 / nw..x1.div_ceil(nw).min(w)) + .map(|x| overlap(x0, x1, x * nw, (x + 1) * nw) * usize::try_from(x).ok().and_then(|x| ones.get(x)).copied().unwrap_or(0)) + .sum(); + // Against what the 0 samples cover: covered is at most w·h, which can't overflow + // (w·h < 2^64 for u32 sizes). + let zeros = w * h - covered; + let is_mixed = covered > 0 && zeros > 0; + mixed += u64::from(is_mixed); + let column = usize::try_from(i).ok()?; + let above = *previous_mixed.get(column)?; + local_tone |= is_mixed && left_mixed && above && above_left_mixed; + *previous_mixed.get_mut(column)? = is_mixed; + left_mixed = is_mixed; + above_left_mixed = above; + if covered > zeros || (dark_is_one && covered == zeros) { + let i = usize::try_from(i).ok()?; + *out_row.get_mut(i / 8)? |= 0x80 >> (i % 8); + } + } + } + Some((out, mixed, local_tone)) +} + +/// A black-and-white image (see [`one_bit`]) resampled and stored again per `settings`, if that is +/// smaller as stored; `ppi` is its effective resolution, and `defaults` says it is drawn where +/// `/DefaultGray` or `/DefaultRGB` is or may be in force. Also says whether it was resampled. +/// +/// - **Resampled** ([`downsample_bits_checked`]) when its resolution is above `above_ppi`, to `target_ppi`, +/// if its dark sample is known ([`dark_is_one`]: grey, an image mask, or an indexed image whose +/// palette says which entry is darker, not under `defaults`), unless it has a soft mask, whose +/// own size would then have to follow, or is a halftone or a dither: most of its new pixels would +/// cover both black and white, and taking the sample that covers more would turn its tones solid +/// (a 50% screen black, a 25% one white). So would a square of four new pixels that each cover +/// both, as a small screened picture on a white page has (and ragged scanned text, which is then +/// left at its resolution too). +/// - **Stored** as CCITT Group 4 with `CcittG4`, or JBIG2 with `Jbig2`, or as 1-bit Flate when that +/// is smaller, which only a resampled image or one packed from more bits may be: a 1-bit image +/// that is only stored again becomes Group 4 or JBIG2, or stays. Group 4 alone is only decoded +/// to be resampled, and so is JBIG2 alone when Group 4 is asked for; Group 3, Group 4 inside +/// another filter, and JBIG2 otherwise are stored again like any other. With `Retain`, a resampled image keeps its kind of compression (CCITT, JBIG2, or +/// Flate), and nothing else changes: an image of more bits stays as it is, for the grey or +/// colour settings. A JBIG2 image stored again refers to no `/JBIG2Globals`. +/// +/// The samples keep their meaning, so the colour space, decode array and `/Mask` stay, but for +/// black-and-white RGB, which becomes grey with one decode pair, and an indexed image's default +/// decode array, which goes as the default at one bit. +fn mono(doc: &Document, s: &Stream, ppi: f64, defaults: bool, settings: &MonoSettings, budget: &mut ImageBudget) -> Option<(Stream, bool)> { + let d = &s.dict; + let downsample = settings.downsample && ppi.is_finite() && ppi > settings.above_ppi && settings.target_ppi > 0.0 && !d.contains(b"SMask"); + let retain = settings.compression == MonoCompression::Retain; + let fax = doc.fax_params(s); + // Its kind of compression, which Retain keeps, behind any other filters. + let ccitt = fax.is_some(); + let jbig2 = matches!(s.filters().last(), Some((pdfcraft_filters::Filter::Jbig2, _))); + // Group 4 alone is only worth decoding to resample it, and so is JBIG2 alone when Group 4 is what + // it would be stored as: that output is rarely smaller, and decoding an ordinary scan costs a + // quarter of a second a page. Group 3, Group 4 behind ASCII85Decode, say, and JBIG2 stored as + // JBIG2 (whose symbols may be smaller than its own coding) can be smaller stored again alone. + let alone_to_g4 = s.filters().len() == 1 && settings.compression == MonoCompression::CcittG4; + let only_to_resample = retain || (alone_to_g4 && (jbig2 || fax.is_some_and(|p| p.k < 0))); + // What can't be averaged, whatever it holds, comes to the same `None` below, after a decode that + // would spend the run's allowance on it. + let too_wide = d.int(b"Width").is_some_and(|w| w > i64::from(pdfcraft_filters::MAX_CCITT_COLUMNS)); + if only_to_resample && (!downsample || defaults || too_wide || settings.target_ppi >= ppi || (jbig2 && dark_is_one(doc, d).is_none())) { + return None; + } + let (data, w, h, packed) = one_bit(doc, s, !retain, defaults, budget)?; + let packed_from_more = packed.is_some(); + let k = settings.target_ppi / ppi; + // `downsample_bits` keeps a counter for each column: no more columns than a fax row may have, + // so a hostile strip of 80 million can't make it 640 MB. + let narrow = w <= pdfcraft_filters::MAX_CCITT_COLUMNS; + // At most `w` × `h`, as k < 1: no overflow. + let (nw, nh) = if downsample && narrow && k < 1.0 { + (((f64::from(w) * k).round() as u32).max(1), ((f64::from(h) * k).round() as u32).max(1)) + } else { + (w, h) + }; + let polarity = if defaults || (nw, nh) == (w, h) { None } else { dark_is_one(doc, packed.as_ref().unwrap_or(d)) }; + let averaged = if polarity.is_some() { + // The dark sample of the image as it ends up: packed RGB is grey, with grey's decode pair. + let (out, mixed, local_tone) = downsample_bits_checked(&data, w, h, nw, nh, polarity?)?; + // A halftone or dither: most new pixels cover both samples. Text covers both only along + // its edges. + // A dense 2x2 patch of mixed output samples can encode a local photo even when + // white margins dominate the page. Conservatively retain the original image. + (!local_tone && mixed.saturating_mul(2) <= u64::from(nw) * u64::from(nh)).then_some(out) + } else { + None + }; + let resampled = averaged.is_some(); + let (data, nw, nh) = match averaged { + Some(out) => (out, nw, nh), + None => (data, w, h), + }; + if only_to_resample && !resampled { + return None; + } + let mut dict = packed.unwrap_or_else(|| d.clone()); + for key in [&b"Filter"[..], b"DecodeParms", b"Length", b"DL"] { + dict.remove(key); + } + dict.set(b"Width".to_vec(), Object::Int(i64::from(nw))); + dict.set(b"Height".to_vec(), Object::Int(i64::from(nh))); + let g4 = if settings.compression == MonoCompression::CcittG4 || (retain && ccitt) { + pdfcraft_filters::encode_g4(&data, nw, nh).ok().map(|coded| { + let mut dict = dict.clone(); + let mut parms = Dict::new(); + parms.set(b"K".to_vec(), Object::Int(-1)); + parms.set(b"Columns".to_vec(), Object::Int(i64::from(nw))); + parms.set(b"Rows".to_vec(), Object::Int(i64::from(nh))); + dict.set(b"Filter".to_vec(), Object::name("CCITTFaxDecode")); + dict.set(b"DecodeParms".to_vec(), Object::Dict(parms)); + Stream::from_raw(dict, coded) + }) + } else { + None + }; + let jbig = if settings.compression == MonoCompression::Jbig2 || (retain && jbig2) { + pdfcraft_filters::encode_jbig2(&data, nw, nh).ok().map(|coded| { + let mut dict = dict.clone(); + dict.set(b"Filter".to_vec(), Object::name("JBIG2Decode")); + Stream::from_raw(dict, coded) + }) + } else { + None + }; + let flate = ((resampled || packed_from_more) && !(retain && (ccitt || jbig2))).then(|| Stream::flate(dict, &data)); + // Smaller as stored, counting the dictionary: a tiny image can lose by Group 4's parameters. + let best = [g4, jbig, flate].into_iter().flatten().filter_map(|c| Some((crate::stored_len(&c)?, c))).min_by_key(|(len, _)| *len)?; + (best.0 < crate::stored_len(s)?).then_some((best.1, resampled)) +} + +/// One pass over the document's images, within `budget`. An image past it stays as it is, and is +/// counted in the report. pub(crate) fn run( doc: &mut Document, pages: &[ObjRef], settings: &Settings, report: &mut Report, progress: &mut dyn FnMut(Stage) -> bool, + budget: &mut ImageBudget, ) -> Result<(), OptimizeError> { - let ppi = effective_resolutions(doc, pages); + let (ppi, mut defaults, read) = walk_pages(doc, pages); + // Black-and-white RGB becomes grey, which default colour spaces would draw otherwise, and an + // indexed image's palette says which of its samples a resampled one keeps, which they may + // change: one used where the walk didn't look may be drawn so. + let packs = settings.mono.compression != MonoCompression::Retain; + let checked: HashSet = + ppi.keys().filter(|r| (packs && device_rgb(doc, **r)) || (settings.mono.downsample && indexed(doc, **r))).map(|r| r.num).collect(); + defaults.extend(used_elsewhere(doc, &checked, &read)); // Soft masks are processed with their image, not on their own. let masks: HashSet = ppi .keys() @@ -551,6 +1238,7 @@ pub(crate) fn run( let mut refs: Vec<(ObjRef, f64)> = ppi.into_iter().filter(|(r, _)| !masks.contains(r)).collect(); refs.sort_by_key(|(r, _)| r.num); let total = refs.len(); + let mut jbig2 = false; for (done, (r, ppi)) in refs.into_iter().enumerate() { if !progress(Stage::Images { done, total }) { return Err(OptimizeError::Cancelled); @@ -558,9 +1246,58 @@ pub(crate) fn run( let obj = doc.get(r); let Object::Stream(s) = &*obj else { continue }; report.images += 1; - let Some(n) = s.dict.get(b"ColorSpace").and_then(|cs| components(doc, cs)) else { continue }; - let st = if n == 1 { &settings.gray } else { &settings.color }; - let Some((new, smask)) = process(doc, s, ppi, st, n) else { continue }; + let refused = budget.refused(); + // Packed from more bits a sample, it is lossless at one bit, but the grey or colour + // settings can make it smaller still (below). + let packed_from_more = s.dict.int(b"BitsPerComponent").is_some_and(|b| b > 1); + let (packed, mono) = match mono(doc, s, ppi, defaults.contains(&r.num), &settings.mono, budget) { + Some(m) if packed_from_more => (Some(m), None), + m => (None, m), + }; + if let Some((new, resampled)) = mono { + report.image_bytes_before += s.raw.len(); + report.image_bytes_after += new.raw.len(); + if resampled { + report.images_resampled += 1; + } else { + report.images_recompressed += 1; + } + jbig2 |= new.dict.name(b"Filter") == Some(b"JBIG2Decode"); + doc.set(r, Object::Stream(new)); + continue; + } + let n = s.dict.get(b"ColorSpace").and_then(|cs| components(doc, cs)); + let processed = n.and_then(|n| process(doc, s, ppi, if n == 1 { &settings.gray } else { &settings.color }, n, budget)); + // A dithered scan, say, downsampled and JPEG-compressed: the smaller wins, counting a soft + // mask resampled with it. + let smask_len = |smask: Option<&(ObjRef, Stream)>| match smask { + Some((_, m)) => Some(m.raw.len()), + None => s.dict.reference(b"SMask").map(|m| match &*doc.get(m) { + Object::Stream(m) => m.raw.len(), + _ => 0, + }), + }; + let total = |new: &Stream, smask: Option<&(ObjRef, Stream)>| Some(crate::stored_len(new)?.saturating_add(smask_len(smask).unwrap_or(0))); + let packed_wins = packed.as_ref().is_some_and(|(p, _)| { + processed.as_ref().is_none_or(|(new, smask)| total(p, None).zip(total(new, smask.as_ref())).is_some_and(|(a, b)| a <= b)) + }); + if let Some((new, resampled)) = packed.filter(|_| packed_wins) { + jbig2 |= new.dict.name(b"Filter") == Some(b"JBIG2Decode"); + report.image_bytes_before += s.raw.len(); + report.image_bytes_after += new.raw.len(); + if resampled { + report.images_resampled += 1; + } else { + report.images_recompressed += 1; + } + doc.set(r, Object::Stream(new)); + continue; + } + let Some((new, smask)) = processed else { + // Read to be packed and again to be resampled, it is still one image, and it stayed. + report.images_refused += usize::from(budget.refused() > refused); + continue; + }; let resized = new.dict.int(b"Width") != s.dict.int(b"Width") || new.dict.int(b"Height") != s.dict.int(b"Height"); report.image_bytes_before += s.raw.len(); report.image_bytes_after += new.raw.len(); @@ -577,6 +1314,23 @@ pub(crate) fn run( if !progress(Stage::Images { done: total, total }) { return Err(OptimizeError::Cancelled); } + if jbig2 { + at_least_pdf_1_4(doc)?; + } + Ok(()) +} + +/// JBIG2Decode is PDF 1.4: a document whose header says less, and whose catalog doesn't say 1.4 +/// or more already (directly or by reference), gets `/Version /1.4` in its catalog (ISO 32000-2 +/// §7.7.2). A full save with object streams writes a 1.5 header anyway. +fn at_least_pdf_1_4(doc: &mut Document) -> Result<(), OptimizeError> { + let below = |v: &[u8]| v.len() < 3 || v < &b"1.4"[..]; + let Some(root) = doc.root() else { return Ok(()) }; + let version = doc.get(root).as_dict().and_then(|c| c.get(b"Version").cloned()); + let catalog = version.and_then(|v| doc.resolve(&v).as_name().map(<[u8]>::to_vec)); + if below(doc.version().as_bytes()) && catalog.as_deref().is_none_or(below) { + doc.update_dict(root, |c| c.set(b"Version".to_vec(), Object::name("1.4")))?; + } Ok(()) } @@ -591,13 +1345,321 @@ mod dimension_tests { dict.set(b"Width".to_vec(), Object::Int(width)); dict.set(b"Height".to_vec(), Object::Int(height)); let stream = Stream::from_raw(dict, vec![0; 4]); - assert!(pixels(&Document::new_empty(), &stream, channels).is_none(), "{width} x {height} x {channels}"); + assert!( + pixels(&Document::new_empty(), &stream, channels, &mut ImageBudget::with_work(u64::MAX)).is_none(), + "{width} x {height} x {channels}" + ); } let mut dict = Dict::new(); dict.set(b"Width".to_vec(), Object::Int(2)); dict.set(b"Height".to_vec(), Object::Int(2)); let stream = Stream::from_raw(dict, vec![1, 2, 3, 4, 5]); - assert_eq!(pixels(&Document::new_empty(), &stream, 1), Some(vec![1, 2, 3, 4])); + assert_eq!(pixels(&Document::new_empty(), &stream, 1, &mut ImageBudget::with_work(u64::MAX)), Some(vec![1, 2, 3, 4])); + } + + /// JBIG2 alone, with Group 4 asked for, is decoded only to be resampled. A cost guard: dropped, + /// no output changes, and an ordinary scan pays a quarter of a second a page for nothing. Behind + /// another filter it can still be stored again, as Group 4 can. + #[test] + fn a_jbig2_image_alone_is_not_decoded_for_group_4_unless_it_is_resampled() { + let coded = pdfcraft_filters::encode_jbig2_generic(&[0xFF; 4], 16, 2).unwrap(); + let hex: Vec = coded.iter().flat_map(|b| format!("{b:02x}").into_bytes()).chain(*b">").collect(); + let image = |filter: Object, data: Vec| { + let mut dict = Dict::new(); + dict.set(b"Width".to_vec(), Object::Int(16)); + dict.set(b"Height".to_vec(), Object::Int(2)); + dict.set(b"BitsPerComponent".to_vec(), Object::Int(1)); + dict.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + dict.set(b"Filter".to_vec(), filter); + Stream::from_raw(dict, data) + }; + let settings = MonoSettings { compression: MonoCompression::CcittG4, ..MonoSettings::REDUCE }; + let decoded = |s: &Stream, settings: &MonoSettings| { + DECODED_PIXELS.with(|c| c.set(0)); + mono(&Document::new_empty(), s, 300.0, false, settings, &mut ImageBudget::with_work(u64::MAX)); + DECODED_PIXELS.with(|c| c.get()) + }; + let alone = image(Object::name("JBIG2Decode"), coded.clone()); + assert_eq!(decoded(&alone, &settings), 0, "alone, for Group 4, below the threshold"); + let behind = image(Object::Array(vec![Object::name("ASCIIHexDecode"), Object::name("JBIG2Decode")]), hex); + assert!(decoded(&behind, &settings) > 0, "behind another filter it can be stored again"); + let jbig2 = MonoSettings { compression: MonoCompression::Jbig2, ..MonoSettings::REDUCE }; + assert!(decoded(&alone, &jbig2) > 0, "stored as JBIG2 its symbols may come out smaller"); + } + + /// A run has one allowance for all its JBIG2 images. Those past it stay as they are, and the + /// report says how many: a file of thousands of them can't hold a run for hours. + #[test] + fn jbig2_images_past_the_runs_allowance_stay_as_they_are() { + let coded = pdfcraft_filters::encode_jbig2_generic(&[0xF0; 4], 16, 2).unwrap(); + let mut dict = Dict::new(); + dict.set(b"Type".to_vec(), Object::name("XObject")); + dict.set(b"Subtype".to_vec(), Object::name("Image")); + dict.set(b"Width".to_vec(), Object::Int(16)); + dict.set(b"Height".to_vec(), Object::Int(2)); + dict.set(b"BitsPerComponent".to_vec(), Object::Int(1)); + dict.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + dict.set(b"Filter".to_vec(), Object::name("JBIG2Decode")); + let image = Stream::from_raw(dict, coded); + let mut cost = ImageBudget::with_work(u64::MAX); + cost.decode(&Document::new_empty(), &image, 1 << 20).unwrap(); + let cost = u64::MAX - cost.work_left(); + + let mut doc = Document::new_empty(); + let refs = [doc.add(Object::Stream(image.clone())), doc.add(Object::Stream(image.clone())), doc.add(Object::Stream(image.clone()))]; + let mut xobjects = Dict::new(); + for (i, r) in refs.iter().enumerate() { + xobjects.set(format!("I{i}").into_bytes(), Object::Ref(*r)); + } + let mut resources = Dict::new(); + resources.set(b"XObject".to_vec(), Object::Dict(xobjects)); + // Drawn small enough, at 720 ppi, for the settings to resample all three. + let content = doc.add(Object::Stream(Stream::flate(Dict::new(), b"q 1.6 0 0 .2 0 0 cm /I0 Do /I1 Do /I2 Do Q"))); + let mut page = Dict::new(); + page.set(b"Type".to_vec(), Object::name("Page")); + page.set(b"Resources".to_vec(), Object::Dict(resources)); + page.set(b"Contents".to_vec(), Object::Ref(content)); + let page = doc.add(Object::Dict(page)); + + let settings = Settings { mono: MonoSettings { compression: MonoCompression::CcittG4, ..MonoSettings::REDUCE }, ..Settings::default() }; + let mut report = Report::default(); + let mut budget = ImageBudget::with_work(2 * cost); + run(&mut doc, &[page], &settings, &mut report, &mut |_| true, &mut budget).unwrap(); + assert_eq!(report.images_refused, 1); + assert_eq!(budget.work_left(), 0, "two decoded and the third refused"); + assert_eq!(report.images_resampled, 2); + let changed = refs.iter().filter(|r| matches!(&*doc.get(**r), Object::Stream(s) if *s != image)).count(); + assert_eq!(changed, 2, "the third is as it was"); + } + + /// Images that aren't JBIG2 share the run's allowance too, both on the way to being resampled + /// and on the way to being packed to a bit a sample: a file of filter bombs would hold a run + /// as long as one of JBIG2, and those past it stay as they are. + #[test] + fn flate_images_past_the_runs_allowance_stay_as_they_are() { + // Noise, resampled, or black and white, packed. + let mut seed = 0x2545_F491_4F6C_DD1Du64; + let noise: Vec = (0..256 * 256) + .map(|_| { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + seed as u8 + }) + .collect(); + let black_and_white: Vec = noise.iter().map(|v| if v & 1 == 0 { 0 } else { 255 }).collect(); + for (name, samples, settings) in [ + ("resampled", &noise, Settings::default()), + ( + "packed", + &black_and_white, + Settings { mono: MonoSettings { compression: MonoCompression::CcittG4, ..MonoSettings::REDUCE }, ..Settings::default() }, + ), + ] { + let page_of = |images: usize| { + let mut doc = Document::new_empty(); + let mut dict = Dict::new(); + dict.set(b"Subtype".to_vec(), Object::name("Image")); + dict.set(b"Width".to_vec(), Object::Int(256)); + dict.set(b"Height".to_vec(), Object::Int(256)); + dict.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + dict.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + let image = Stream::flate(dict, samples); + let refs: Vec = (0..images).map(|_| doc.add(Object::Stream(image.clone()))).collect(); + let mut xobjects = Dict::new(); + let mut drawn = String::from("q 10 0 0 10 0 0 cm "); + for (i, r) in refs.iter().enumerate() { + xobjects.set(format!("I{i}").into_bytes(), Object::Ref(*r)); + drawn.push_str(&format!("/I{i} Do ")); + } + drawn.push('Q'); + let mut resources = Dict::new(); + resources.set(b"XObject".to_vec(), Object::Dict(xobjects)); + let content = doc.add(Object::Stream(Stream::flate(Dict::new(), drawn.as_bytes()))); + let mut page = Dict::new(); + page.set(b"Type".to_vec(), Object::name("Page")); + page.set(b"Resources".to_vec(), Object::Dict(resources)); + page.set(b"Contents".to_vec(), Object::Ref(content)); + let page = doc.add(Object::Dict(page)); + (doc, page, refs, image) + }; + let (mut doc, page, ..) = page_of(1); + let mut budget = ImageBudget::with_work(u64::MAX); + let mut report = Report::default(); + run(&mut doc, &[page], &settings, &mut report, &mut |_| true, &mut budget).unwrap(); + let cost = u64::MAX - budget.work_left(); + assert!(cost > 0, "{name}"); + assert_eq!(report.images_refused, 0, "{name}"); + + let (mut doc, page, refs, image) = page_of(3); + let mut budget = ImageBudget::with_work(2 * cost); + let mut report = Report::default(); + run(&mut doc, &[page], &settings, &mut report, &mut |_| true, &mut budget).unwrap(); + assert_eq!(report.images_refused, 1, "{name}: {report:?}"); + assert_eq!(budget.work_left(), 0, "{name}"); + let changed = refs.iter().filter(|r| matches!(&*doc.get(**r), Object::Stream(s) if *s != image)).count(); + assert_eq!(changed, 2, "{name}: the third is as it was"); + } + } + + /// An image the allowance ran out on between being packed and being resampled is rewritten, + /// as the packed one, so it isn't one that stayed as it was. + #[test] + fn a_packed_image_isnt_counted_as_refused() { + let mut seed = 0x2545_F491_4F6C_DD1Du64; + let samples: Vec = (0..256 * 256) + .map(|_| { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + if seed & 1 == 0 { 0 } else { 255 } + }) + .collect(); + let mut doc = Document::new_empty(); + let mut dict = Dict::new(); + dict.set(b"Subtype".to_vec(), Object::name("Image")); + dict.set(b"Width".to_vec(), Object::Int(256)); + dict.set(b"Height".to_vec(), Object::Int(256)); + dict.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + dict.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + let image = Stream::flate(dict, &samples); + let read = (image.raw.len() + samples.len()) as u64 + 256 * 256; + let original = doc.add(Object::Stream(image.clone())); + let mut xobjects = Dict::new(); + xobjects.set(b"I0".to_vec(), Object::Ref(original)); + let mut resources = Dict::new(); + resources.set(b"XObject".to_vec(), Object::Dict(xobjects)); + let content = doc.add(Object::Stream(Stream::flate(Dict::new(), b"q 10 0 0 10 0 0 cm /I0 Do Q"))); + let mut page = Dict::new(); + page.set(b"Type".to_vec(), Object::name("Page")); + page.set(b"Resources".to_vec(), Object::Dict(resources)); + page.set(b"Contents".to_vec(), Object::Ref(content)); + let page = doc.add(Object::Dict(page)); + let settings = Settings { mono: MonoSettings { compression: MonoCompression::CcittG4, ..MonoSettings::REDUCE }, ..Settings::default() }; + let mut report = Report::default(); + let mut budget = ImageBudget::with_work(read); + run(&mut doc, &[page], &settings, &mut report, &mut |_| true, &mut budget).unwrap(); + assert_eq!(budget.refused(), 1, "its second read was refused"); + assert_eq!(report.images_refused, 0, "{report:?}"); + assert_eq!(report.images_recompressed + report.images_resampled, 1, "{report:?}"); + assert!(matches!(&*doc.get(original), Object::Stream(s) if *s != image)); + } + + /// An image the run has no use for stayed as it was too, but nothing refused it: this one is + /// too small to compress and drawn at no more than its size. + #[test] + fn an_image_left_alone_isnt_counted_as_refused() { + let mut doc = Document::new_empty(); + let mut dict = Dict::new(); + dict.set(b"Subtype".to_vec(), Object::name("Image")); + dict.set(b"Width".to_vec(), Object::Int(8)); + dict.set(b"Height".to_vec(), Object::Int(8)); + dict.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + dict.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + let image = doc.add(Object::Stream(Stream::flate(dict, &[0x80; 64]))); + let mut xobjects = Dict::new(); + xobjects.set(b"I0".to_vec(), Object::Ref(image)); + let mut resources = Dict::new(); + resources.set(b"XObject".to_vec(), Object::Dict(xobjects)); + let content = doc.add(Object::Stream(Stream::flate(Dict::new(), b"q 72 0 0 72 0 0 cm /I0 Do Q"))); + let mut page = Dict::new(); + page.set(b"Type".to_vec(), Object::name("Page")); + page.set(b"Resources".to_vec(), Object::Dict(resources)); + page.set(b"Contents".to_vec(), Object::Ref(content)); + let page = doc.add(Object::Dict(page)); + let mut report = Report::default(); + run(&mut doc, &[page], &Settings::default(), &mut report, &mut |_| true, &mut ImageBudget::with_work(u64::MAX)).unwrap(); + assert_eq!((report.images, report.images_refused), (1, 0), "{report:?}"); + } + + /// A JPEG pays for its samples before it is decoded, however few bytes it takes: past what is + /// left it isn't, and counts as refused. + #[test] + fn a_jpeg_pays_for_its_samples() { + let mut dict = Dict::new(); + dict.set(b"Width".to_vec(), Object::Int(16)); + dict.set(b"Height".to_vec(), Object::Int(8)); + dict.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + dict.set(b"ColorSpace".to_vec(), Object::name("DeviceRGB")); + let jpeg = image_stream(&dict, &[120; 16 * 8 * 3], 3, 16, 8, Compression::Jpeg(60), false).expect("a JPEG"); + let doc = Document::new_empty(); + let cost = 2 * 16 * 8 * 3 + 4 * jpeg.raw.len() as u64; + let mut budget = ImageBudget::with_work(cost); + assert_eq!(pixels(&doc, &jpeg, 3, &mut budget).map(|p| p.len()), Some(16 * 8 * 3)); + assert_eq!((budget.work_left(), budget.refused()), (0, 0), "exactly that"); + let mut budget = ImageBudget::with_work(cost - 1); + assert!(pixels(&doc, &jpeg, 3, &mut budget).is_none()); + assert_eq!((budget.work_left(), budget.refused()), (0, 1)); + } + + /// A soft mask resampled with its image is read from the same allowance as the image. + #[test] + fn a_soft_mask_resampled_with_its_image_spends_the_allowance() { + let mut seed = 0x2545_F491_4F6C_DD1Du64; + let noise: Vec = (0..256 * 256) + .map(|_| { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + seed as u8 + }) + .collect(); + let image = |space: &str| { + let mut dict = Dict::new(); + dict.set(b"Subtype".to_vec(), Object::name("Image")); + dict.set(b"Width".to_vec(), Object::Int(256)); + dict.set(b"Height".to_vec(), Object::Int(256)); + dict.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + dict.set(b"ColorSpace".to_vec(), Object::name(space)); + Stream::flate(dict, &noise) + }; + let mut doc = Document::new_empty(); + let mask = image("DeviceGray"); + let cost = (mask.raw.len() + noise.len()) as u64 + 256 * 256; + let masked = doc.add(Object::Stream(mask)); + let mut with_mask = image("DeviceGray"); + with_mask.dict.set(b"SMask".to_vec(), Object::Ref(masked)); + let spent = |s: &Stream| { + let mut budget = ImageBudget::with_work(u64::MAX); + process(&doc, s, 720.0, &Settings::default().gray, 1, &mut budget).expect("resampled, and smaller"); + u64::MAX - budget.work_left() + }; + assert_eq!(spent(&with_mask), spent(&image("DeviceGray")) + cost); + } + + /// Decoded only to be resampled, what can't be averaged whatever it holds isn't decoded, so it + /// takes nothing from the images that can be, and isn't counted as refused past the allowance. + #[test] + fn a_jbig2_image_that_cant_be_averaged_spends_nothing() { + let image = |edit: &dyn Fn(&mut Dict)| { + let mut dict = Dict::new(); + dict.set(b"Subtype".to_vec(), Object::name("Image")); + dict.set(b"Width".to_vec(), Object::Int(16)); + dict.set(b"Height".to_vec(), Object::Int(2)); + dict.set(b"BitsPerComponent".to_vec(), Object::Int(1)); + dict.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + dict.set(b"Filter".to_vec(), Object::name("JBIG2Decode")); + edit(&mut dict); + Stream::from_raw(dict, pdfcraft_filters::encode_jbig2_generic(&[0xF0; 4], 16, 2).unwrap()) + }; + let spent_at = |target_ppi: f64, s: &Stream, defaults: bool| { + let settings = MonoSettings { compression: MonoCompression::Retain, above_ppi: 100.0, target_ppi, ..MonoSettings::REDUCE }; + let mut budget = ImageBudget::with_work(u64::MAX); + let _ = mono(&Document::new_empty(), s, 720.0, defaults, &settings, &mut budget); + u64::MAX - budget.work_left() + }; + let spent = |s: &Stream, defaults: bool| spent_at(300.0, s, defaults); + let plain = image(&|_| {}); + assert_eq!(spent_at(720.0, &plain, false), 0, "already at the target"); + assert!(spent_at(719.0, &plain, false) > 0, "just above it"); + assert!(spent(&plain, false) > 0, "one that can be averaged is decoded"); + assert_eq!(spent(&plain, true), 0, "drawn where a device colour space is remapped"); + let width = |w: i64| image(&move |d| d.set(b"Width".to_vec(), Object::Int(w))); + let widest = i64::from(pdfcraft_filters::MAX_CCITT_COLUMNS); + assert!(spent(&width(widest), false) > 0, "the widest that can be averaged is decoded"); + assert_eq!(spent(&width(widest + 1), false), 0, "too wide to average"); + let tinted = image(&|d| d.set(b"ColorSpace".to_vec(), Object::Array(vec![Object::name("CalGray"), Object::Dict(Dict::new())]))); + assert_eq!(spent(&tinted, false), 0, "no way to tell which sample is dark"); } /// A JPEG larger than its dictionary says is refused from its header, before its pixels are decoded. @@ -612,9 +1674,69 @@ mod dimension_tests { Stream::from_raw(dict, data.clone()) }; DECODED_PIXELS.with(|c| c.set(0)); - assert!(pixels(&Document::new_empty(), &stream(10, 10), 1).is_none()); + assert!(pixels(&Document::new_empty(), &stream(10, 10), 1, &mut ImageBudget::with_work(u64::MAX)).is_none()); assert_eq!(DECODED_PIXELS.with(|c| c.get()), 0, "pixels decoded"); - assert_eq!(pixels(&Document::new_empty(), &stream(1000, 1000), 1).map(|p| p.len()), Some(1000 * 1000)); + assert_eq!(pixels(&Document::new_empty(), &stream(1000, 1000), 1, &mut ImageBudget::with_work(u64::MAX)).map(|p| p.len()), Some(1000 * 1000)); + } + + /// Resampling goes through no more than one of the two sizes holds, whatever their proportions: + /// the first three shapes asked for 160 GB and more on the way, and that aborted the process. + /// The last two would ask for 10 GB and 30 GB if they went across first, which is why the way + /// round depends on the shapes. A flat picture stays flat, and pictures of ordinary proportions + /// resample as they always did. + #[test] + fn resampling_across_proportions_takes_the_memory_of_neither_product() { + let shapes = [ + (100_000u32, 1u32, 1u32, 100_000u32, 1usize), + (1_000_000, 1, 4, 100_000, 1), + (100_000, 1, 1, 100_000, 3), + (1, 100_000, 100_000, 1, 1), + (1, 100_000, 100_000, 1, 3), + ]; + for (w, h, nw, nh, n) in shapes { + let px = vec![0x60; (w * h) as usize * n]; + let out = resample(&px, n, w, h, nw, nh).unwrap(); + assert_eq!(out.len(), (nw * nh) as usize * n); + assert!(out.iter().all(|v| v.abs_diff(0x60) <= 1), "{w} × {h} to {nw} × {nh}"); + } + let (w, h, nw, nh) = (40u32, 30u32, 20u32, 15u32); + let px: Vec = (0..w * h).map(|i| (i * 7 % 251) as u8).collect(); + let image = image::GrayImage::from_raw(w, h, px.clone()).unwrap(); + let direct = image::imageops::resize(&image, nw, nh, image::imageops::FilterType::CatmullRom).into_raw(); + assert_eq!(resample(&px, 1, w, h, nw, nh).unwrap(), direct, "as it was"); + } + + /// The long way round is for proportions far apart: up to a quarter over the larger picture the + /// buffer is no danger, and that scales as it always did, to the byte. Noise, as a flat picture comes + /// out the same either way. + #[test] + fn proportions_up_to_the_limit_resample_as_they_did() { + let noise = |w: u32, h: u32| -> Vec { + let mut seed = 0x2545_F491_4F6C_DD1Du64; + (0..w * h) + .map(|_| { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + seed as u8 + }) + .collect() + }; + let direct = |px: &[u8], w, h, nw, nh| { + let image = image::GrayImage::from_raw(w, h, px.to_vec()).unwrap(); + image::imageops::resize(&image, nw, nh, image::imageops::FilterType::CatmullRom).into_raw() + }; + // Each exactly a quarter over the larger: the source's size, then the target's. + for (w, h, nw, nh) in [(25, 16, 10, 20), (20, 5, 16, 25)] { + let px = noise(w, h); + assert_eq!(resample(&px, 1, w, h, nw, nh).unwrap(), direct(&px, w, h, nw, nh), "{w} × {h} to {nw} × {nh}"); + } + // A step past it goes the long way, which is how to tell. + let px = noise(25, 15); + assert_ne!(resample(&px, 1, 25, 15, 10, 20).unwrap(), direct(&px, 25, 15, 10, 20)); + // A picture of the size asked for comes through as it is. + let px = noise(20, 10); + assert_eq!(resample(&px, 1, 20, 10, 20, 10).unwrap(), px); } } @@ -631,3 +1753,186 @@ mod tests { assert!(data.is_none_or(|d| d.len() <= MAX_PAGE_DECODED + 300)); } } + +#[cfg(test)] +mod downsample_tests { + use super::{dark_is_one, downsample_bits}; + use pdfcraft_cos::{Dict, Document, Object}; + + /// [`downsample_bits`] the slow way: each new pixel's area against every source pixel's. + fn naive(data: &[u8], w: u32, h: u32, nw: u32, nh: u32, dark_one: bool) -> Vec { + let (stride, nstride) = ((w as usize).div_ceil(8), (nw as usize).div_ceil(8)); + let (w, h, nw, nh) = (u64::from(w), u64::from(h), u64::from(nw), u64::from(nh)); + let mut out = vec![0u8; nstride * nh as usize]; + for j in 0..nh { + for i in 0..nw { + let mut ones = 0; + for y in 0..h { + for x in 0..w { + let across = ((i + 1) * w).min((x + 1) * nw).saturating_sub((i * w).max(x * nw)); + let down = ((j + 1) * h).min((y + 1) * nh).saturating_sub((j * h).max(y * nh)); + if data[y as usize * stride + x as usize / 8] & (0x80 >> (x % 8)) != 0 { + ones += across * down; + } + } + } + if 2 * ones > w * h || (dark_one && 2 * ones == w * h) { + out[j as usize * nstride + i as usize / 8] |= 0x80 >> (i % 8); + } + } + } + out + } + + #[test] + fn each_new_pixel_takes_the_sample_covering_more_of_it() { + // 4 × 2 to 2 × 1: three 1s of four on the left, none on the right. + assert_eq!(downsample_bits(&[0b1100_0000, 0b1000_0000], 4, 2, 2, 1, false).map(|(out, _)| out), Some(vec![0b1000_0000])); + // A tie gives the dark sample: 0, black in grey, so a black line half a new pixel wide + // stays (the second of four white columns is black); a white one that thin on black doesn't. + assert_eq!(downsample_bits(&[0b1011_0000, 0b1011_0000], 4, 2, 2, 1, false).map(|(out, _)| out), Some(vec![0b0100_0000])); + assert_eq!(downsample_bits(&[0b0100_0000, 0b0100_0000], 4, 2, 2, 1, false).map(|(out, _)| out), Some(vec![0])); + // With 1 the dark sample (an inverting decode array, a separation), that line is a 1. + assert_eq!(downsample_bits(&[0b0100_0000, 0b0100_0000], 4, 2, 2, 1, true).map(|(out, _)| out), Some(vec![0b1000_0000])); + assert_eq!(downsample_bits(&[0b1011_0000, 0b1011_0000], 4, 2, 2, 1, true).map(|(out, _)| out), Some(vec![0b1100_0000])); + // The same size gives the same bits; padding bits are 0. + assert_eq!(downsample_bits(&[0b1010_1111], 3, 1, 3, 1, false).map(|(out, _)| out), Some(vec![0b1010_0000])); + // 3 to 2 across: the middle column counts half to each new one. + assert_eq!(downsample_bits(&[0b1100_0000], 3, 1, 2, 1, false).map(|(out, _)| out), Some(vec![0b1000_0000])); + } + + /// The dark sample: 0 in grey and of an image mask, the other way round with an inverting decode + /// array; none known for a separation, nor for an indexed image without a palette (see the next + /// test), nor with a decode array that isn't two numbers. + #[test] + fn the_dark_sample_follows_the_colour_space_and_decode_array() { + let doc = Document::new_empty(); + let image = |entries: &[(&str, Object)]| { + let mut d = Dict::new(); + for (k, v) in entries { + d.set(k.as_bytes().to_vec(), v.clone()); + } + d + }; + let inverted = ("Decode", Object::Array(vec![1.into(), 0.into()])); + let grey = ("ColorSpace", Object::name("DeviceGray")); + let mask = ("ImageMask", Object::Bool(true)); + let separation = + ("ColorSpace", Object::Array(vec![Object::name("Separation"), Object::name("Spot"), Object::name("DeviceCMYK"), Object::Null])); + let indexed = ("ColorSpace", Object::Array(vec![Object::name("Indexed"), Object::name("DeviceGray"), 1.into(), Object::Null])); + for (entries, dark_one) in [ + (vec![grey.clone()], Some(false)), + (vec![grey, inverted.clone()], Some(true)), + (vec![mask.clone()], Some(false)), + (vec![mask, inverted.clone()], Some(true)), + (vec![separation.clone()], None), + (vec![separation, inverted.clone()], None), + // A palette that isn't there says nothing. + (vec![indexed.clone()], None), + (vec![indexed, inverted], None), + (vec![("ColorSpace", Object::name("DeviceGray")), ("Decode", Object::Array(vec![Object::name("x"), 0.into()]))], None), + ] { + assert_eq!(dark_is_one(&doc, &image(&entries)), dark_one, "{entries:?}"); + } + } + + /// An indexed image's dark sample is the one picking the darker palette entry, after its decode + /// array: over DeviceGray and DeviceRGB (by luma, each weight checked), with the table a string + /// or a stream, directly or by reference. Another base (CMYK, calibrated, an ICC profile, a + /// resource name), two entries alike, an entry the table doesn't hold, and a `hival` past 255 + /// say nothing. + #[test] + fn an_indexed_images_palette_says_which_sample_is_dark() { + let mut doc = Document::new_empty(); + let string = |bytes: &[u8]| Object::String(pdfcraft_cos::PdfString::literal(bytes.to_vec())); + let referenced = doc.add(string(&[255, 0])); + let stream = doc.add(Object::Stream(pdfcraft_cos::Stream::flate(Dict::new(), &[0, 0, 0, 255, 255, 255]))); + let cal = |name: &str| Object::Array(vec![Object::name(name), Object::Dict(Dict::new())]); + let indexed = |base: Object, hival: i64, table: Object| { + let mut d = Dict::new(); + d.set(b"ColorSpace".to_vec(), Object::Array(vec![Object::name("Indexed"), base, hival.into(), table])); + d + }; + let inverted = |mut d: Dict| { + d.set(b"Decode".to_vec(), Object::Array(vec![1.into(), 0.into()])); + d + }; + for (d, dark_one, what) in [ + (indexed(Object::name("DeviceGray"), 1, string(&[0, 255])), Some(false), "grey, black first"), + (indexed(Object::name("DeviceGray"), 1, string(&[255, 0])), Some(true), "grey, white first"), + (indexed(Object::name("DeviceGray"), 1, string(&[90, 89])), Some(true), "two greys a step apart"), + (inverted(indexed(Object::name("DeviceGray"), 1, string(&[255, 0]))), Some(false), "white first, inverted"), + (indexed(Object::name("DeviceGray"), 1, Object::Ref(referenced)), Some(true), "the table by reference"), + (indexed(Object::name("DeviceRGB"), 1, Object::Ref(stream)), Some(false), "RGB in a stream"), + (indexed(Object::name("DeviceRGB"), 1, string(&[255, 255, 0, 0, 0, 255])), Some(true), "yellow, then blue"), + // Pure red (76,245) is lighter than pure blue (29,070), and darker than green (149,685). + (indexed(Object::name("DeviceRGB"), 1, string(&[255, 0, 0, 0, 0, 255])), Some(true), "red, then blue"), + (indexed(Object::name("DeviceRGB"), 1, string(&[255, 0, 0, 0, 255, 0])), Some(false), "red, then green"), + (indexed(Object::name("DeviceRGB"), 1, string(&[0, 0, 255, 0, 255, 0])), Some(false), "blue, then green"), + // Blue (29,070) is lighter than grey 20 (20,000): the blue weight decides it. + (indexed(Object::name("DeviceRGB"), 1, string(&[0, 0, 255, 20, 20, 20])), Some(true), "blue, then dark grey"), + (indexed(Object::name("DeviceCMYK"), 1, string(&[0, 0, 0, 0, 0, 0, 0, 255])), None, "CMYK"), + (indexed(cal("CalGray"), 1, string(&[200, 20])), None, "calibrated grey"), + (indexed(cal("ICCBased"), 1, string(&[0, 255])), None, "an ICC profile"), + (indexed(Object::name("CS0"), 1, string(&[0, 255])), None, "a resource name"), + (indexed(Object::name("DeviceGray"), 1, string(&[7, 7])), None, "two entries alike"), + (indexed(Object::name("DeviceGray"), 1, string(&[0])), None, "a table of one entry"), + (indexed(Object::name("DeviceGray"), 0, string(&[0, 255])), None, "hival 0: both samples pick entry 0"), + (indexed(Object::name("DeviceGray"), 256, string(&[0, 255])), None, "hival past 255"), + ] { + assert_eq!(dark_is_one(&doc, &d), dark_one, "{what}"); + } + } + + #[test] + fn what_it_cannot_resample_is_none() { + assert_eq!(downsample_bits(&[0; 2], 4, 2, 5, 1, false).map(|(out, _)| out), None, "wider"); + assert_eq!(downsample_bits(&[0; 2], 4, 2, 2, 3, false).map(|(out, _)| out), None, "taller"); + assert_eq!(downsample_bits(&[0; 2], 4, 2, 0, 1, false).map(|(out, _)| out), None); + assert_eq!(downsample_bits(&[0; 1], 4, 2, 2, 1, false).map(|(out, _)| out), None, "short rows"); + assert_eq!(downsample_bits(&[], u32::MAX, u32::MAX, 1, 1, true).map(|(out, _)| out), None); + } + + proptest::proptest! { + /// Any bitmap, to any size no larger, with either sample the dark one, gives what the slow + /// way gives; all 1s stay all 1s. + #[test] + fn it_matches_the_slow_way( + w in 1u32..40, + h in 1u32..16, + nw_part in 0.0f64..1.0, + nh_part in 0.0f64..1.0, + seed in proptest::collection::vec(0u8..=255, 1..64), + dark_one: bool, + ) { + let (nw, nh) = (1 + (f64::from(w - 1) * nw_part) as u32, 1 + (f64::from(h - 1) * nh_part) as u32); + let len = (w as usize).div_ceil(8) * h as usize; + let data: Vec = seed.iter().cycle().take(len).copied().collect(); + proptest::prop_assert_eq!(downsample_bits(&data, w, h, nw, nh, dark_one).map(|(out, _)| out), Some(naive(&data, w, h, nw, nh, dark_one))); + let white = downsample_bits(&vec![0xFF; len], w, h, nw, nh, dark_one).map(|(out, _)| out).unwrap(); + proptest::prop_assert_eq!(white, naive(&vec![0xFF; len], w, h, nw, nh, dark_one)); + proptest::prop_assert!(naive(&vec![0xFF; len], w, h, nw, nh, dark_one).iter().any(|b| *b != 0)); + } + } +} + +#[cfg(test)] +mod pack_tests { + use super::{Pixel, packed_rows}; + + /// Rows of one pixel of one bit, 1 white: each packed as it is. + const WHITE_IS_1: Pixel = |v| Some(v == [1]); + + /// [`packed_rows`] packs rows it can read whole, and refuses any other shape, without reading + /// past a row or allocating more than its samples: no pixels in a row, no components, samples + /// of 3 bits, several components under 8 bits, rows shorter than their pixels, and a width far + /// past the samples. + #[test] + fn packed_rows_refuses_rows_it_cannot_read_whole() { + let samples = [0x80, 0x00, 0x80, 0x00]; + assert_eq!(packed_rows(&samples, 1, 1, 4, (1, 1, WHITE_IS_1)), Some(vec![0x80, 0x00, 0x80, 0x00])); + for (row_len, w, n, bpc) in [(1, 0, 1, 1), (1, 1, 0, 8), (1, 1, 1, 3), (1, 1, 2, 4), (1, 9, 1, 1), (1, usize::MAX / 8, 1, 1)] { + assert_eq!(packed_rows(&samples, row_len, w, 4, (n, bpc, WHITE_IS_1)), None, "row of {row_len} for {w} × {n} × {bpc}"); + } + } +} diff --git a/crates/optimize/src/lib.rs b/crates/optimize/src/lib.rs index 9f9fcdf2be..e70aa5e71d 100644 --- a/crates/optimize/src/lib.rs +++ b/crates/optimize/src/lib.rs @@ -7,10 +7,15 @@ //! - **Images:** each image's effective resolution is measured where pages draw it (the //! smallest over its uses, through form XObjects); colour and grayscale images above a //! threshold are resampled (bicubic) to a target resolution and recompressed as JPEG or Flate. -//! A new image replaces the old one only if it is smaller. Images PdfCraft can't decode -//! faithfully (CMYK and other colour spaces, masks, decode arrays other than the default, -//! JPEG 2000, JBIG2, CCITT, other than 8 bits, JPEGs whose Adobe marker and `/ColorTransform` -//! disagree) are left alone. +//! A new image replaces the old one only if it is smaller. The colour and grey settings leave +//! alone images they can't decode faithfully (CMYK and other colour spaces, masks, decode arrays +//! other than the default, JPEG 2000, JBIG2, CCITT, other than 8 bits, JPEGs whose Adobe marker +//! and `/ColorTransform` disagree). Black-and-white images (1-bit, image masks, CCITT fax and JBIG2, +//! Group 3 among them; and eight-bit grey and RGB of only black and white, and indexed images +//! that use only palette entries 0 and 1, packed to one bit) above a threshold are averaged down to a +//! target resolution when grey, image masks or indexed, halftones and dithers aside (see the README), and +//! are stored as CCITT Group 4 or lossless JBIG2, or 1-bit +//! Flate once resampled or packed, when that is smaller. //! - **Discard objects:** page thumbnails, alternate images, document tags (structure tree), //! print settings. //! - **Clean up:** drop images and forms that pages list but never draw (such as a deleted @@ -31,7 +36,7 @@ mod unused; pub use audit::{SpaceCategory, SpaceUse, audit_space}; -use pdfcraft_cos::{Dict, Document, MAX_PAGE_DECODED, ObjRef, Object, Stream, serialize}; +use pdfcraft_cos::{Dict, Document, ImageBudget, MAX_PAGE_DECODED, ObjRef, Object, Stream, serialize}; use pdfcraft_filters::{Filter, Params}; pub use images::effective_resolutions; @@ -118,6 +123,37 @@ pub enum Compression { Retain, } +/// How black-and-white (1-bit) images are stored. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MonoCompression { + /// CCITT Group 4 (lossless), when smaller than the image as it is. + CcittG4, + /// JBIG2, lossless (a generic region, or symbols when the image's marks repeat), when smaller + /// than the image as it is. JBIG2Decode is PDF 1.4: an older document gets `/Version /1.4` in + /// its catalog. + Jbig2, + /// Keep each image's own compression. + Retain, +} + +/// How black-and-white images (1-bit, CCITT fax among them; and eight-bit grey and RGB of only black +/// and white, and indexed images that use only palette entries 0 and 1, packed to one bit), and +/// image masks, are resampled and stored. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct MonoSettings { + /// Average images whose effective resolution is above `above_ppi` down to `target_ppi`. + pub downsample: bool, + pub target_ppi: f64, + pub above_ppi: f64, + pub compression: MonoCompression, +} + +impl MonoSettings { + /// Reduce File Size and the presets: to 300 ppi above 450 ppi, CCITT Group 4. Text in a scan + /// stays sharp at 300 ppi. + pub const REDUCE: MonoSettings = MonoSettings { downsample: true, target_ppi: 300.0, above_ppi: 450.0, compression: MonoCompression::CcittG4 }; +} + /// Acrobat's JPEG quality steps. pub const QUALITIES: [(&str, u8); 5] = [("Minimum", 25), ("Low", 45), ("Medium", 60), ("High", 80), ("Maximum", 95)]; @@ -147,6 +183,8 @@ pub struct Settings { pub merge_fragments: bool, pub color: ImageSettings, pub gray: ImageSettings, + /// Black-and-white images, and image masks. + pub mono: MonoSettings, pub discard_thumbnails: bool, pub discard_alternate_images: bool, pub discard_tags: bool, @@ -181,6 +219,7 @@ impl Default for Settings { merge_fragments: true, color: ImageSettings::REDUCE, gray: ImageSettings::REDUCE, + mono: MonoSettings::REDUCE, discard_thumbnails: true, discard_alternate_images: true, discard_tags: false, @@ -240,6 +279,8 @@ pub struct Report { pub images: usize, pub images_resampled: usize, pub images_recompressed: usize, + /// Images left as they are: decoding them all would take more than one run may spend. + pub images_refused: usize, /// Encoded image bytes before and after (images that changed). pub image_bytes_before: usize, pub image_bytes_after: usize, @@ -290,10 +331,12 @@ pub fn optimize_with_progress(doc: &mut Document, settings: &Settings, progress: } let mut report = Report::default(); let pages = pdfcraft_annot::page_refs(doc).map_err(|_| OptimizeError::NoPages)?; + // Merging fragments, the image pass and the LZW pass read the same images, so they share it. + let mut budget = ImageBudget::new(doc); if settings.merge_fragments { - fragments::run(doc, &pages, &mut report, progress)?; + fragments::run(doc, &pages, &mut report, progress, &mut budget)?; } - images::run(doc, &pages, settings, &mut report, progress)?; + images::run(doc, &pages, settings, &mut report, progress, &mut budget)?; if !progress(Stage::CleanUp) { return Err(OptimizeError::Cancelled); } @@ -389,7 +432,11 @@ pub fn optimize_with_progress(doc: &mut Document, settings: &Settings, progress: let r = ObjRef { num, generation: doc.generation(num) }; let obj = doc.get(r); let Object::Stream(s) = &*obj else { continue }; - if let Some(replacement) = lzw_to_flate(s, MAX_LZW_DECODED) { + // Each can take a second or more, of a pass that has no other way to stop. + if s.filters().iter().any(|(f, _)| *f == Filter::Lzw) && !progress(Stage::CleanUp) { + return Err(OptimizeError::Cancelled); + } + if let Some(replacement) = lzw_to_flate(s, MAX_LZW_DECODED, &mut budget) { doc.set(r, Object::Stream(replacement)); report.lzw_streams += 1; } @@ -424,14 +471,17 @@ pub fn optimize_with_progress(doc: &mut Document, settings: &Settings, progress: /// Largest decoded LZW stream converted, as for a page's content: the data and its check are /// held together, so a few hundred kilobytes of hostile LZW can't ask for gigabytes. const MAX_LZW_DECODED: usize = MAX_PAGE_DECODED; +/// The steps of a run's allowance a decoded byte costs to store with Flate and read back: up to +/// 78 ns a byte to encode and 4 to check, at the 6 to 16 ns of a step. +const LZW_REWRITE_WORK: u64 = 8; /// `s` with its LZW compression replaced by Flate (Acrobat's "In streams that use LZW encoding, /// use Flate instead"). The filters up to the last LZW one are decoded, strictly and with its /// predictor; the result is Flate-encoded under the same predictor, without `EarlyChange`, and /// the filters after it keep their encoded bytes. A stream that doesn't decode cleanly, or -/// whose filters or parameters can't be read for certain, or that decodes past `max` bytes, is -/// left as it is. -fn lzw_to_flate(s: &Stream, max: usize) -> Option { +/// whose filters or parameters can't be read for certain, or that decodes past `max` bytes or +/// what `budget` has left, is left as it is. +fn lzw_to_flate(s: &Stream, max: usize, budget: &mut ImageBudget) -> Option { // An external file's data isn't in `raw`; cross-reference and object streams are rewritten // on save, so converting them would change nothing in the file. if s.dict.contains(b"F") || matches!(s.dict.name(b"Type"), Some(b"XRef" | b"ObjStm")) { @@ -474,7 +524,10 @@ fn lzw_to_flate(s: &Stream, max: usize) -> Option { if !parms.get(..=lzw)?.iter().all(direct) { return None; } - let data = pdfcraft_filters::decode(stages, &s.raw, max).ok()?; + let data = budget.decode_chain(stages, &s.raw, max).ok()?; + if !budget.spend((data.len() as u64).saturating_mul(LZW_REWRITE_WORK)) { + return None; + } let flate = Params { early_change: 1, ..params.clone() }; let raw = pdfcraft_filters::encode(&Filter::Flate, &flate, &data).ok()?; // The predictor's encoder must give back exactly what its decoder read (a short last row, @@ -539,7 +592,7 @@ fn strip_ascii_wrapper(s: &Stream) -> Option { /// Bytes the stream object takes in the file: its dictionary with `/Length` set as the writer /// sets it, then its data. -fn stored_len(s: &Stream) -> Option { +pub(crate) fn stored_len(s: &Stream) -> Option { let mut dict = s.dict.clone(); dict.set(b"Length".to_vec(), Object::Int(i64::try_from(s.raw.len()).ok()?)); let mut out = Vec::new(); @@ -558,8 +611,31 @@ mod tests { let mut dict = Dict::new(); dict.set(b"Filter".to_vec(), Object::name("LZWDecode")); let s = Stream::from_raw(dict, raw); - assert!(lzw_to_flate(&s, data.len() - 1).is_none()); - let converted = lzw_to_flate(&s, data.len()).unwrap(); + let mut budget = ImageBudget::with_work(u64::MAX); + assert!(lzw_to_flate(&s, data.len() - 1, &mut budget).is_none()); + let converted = lzw_to_flate(&s, data.len(), &mut budget).unwrap(); assert_eq!(converted.decoded_strict().unwrap(), data); } + + /// Rewriting a stream spends the run's allowance: reading it, decoding it, and storing and + /// checking what it decoded. One the allowance can't pay for stays LZW, and ends the rewriting. + #[test] + fn lzw_streams_spend_the_runs_allowance() { + let data = b"0 0 m 612 792 l S\n".repeat(64); + let raw = pdfcraft_filters::encode(&Filter::Lzw, &Params::default(), &data).unwrap(); + let mut dict = Dict::new(); + dict.set(b"Filter".to_vec(), Object::name("LZWDecode")); + let s = Stream::from_raw(dict, raw); + let cost = (s.raw.len() + data.len()) as u64 + data.len() as u64 * LZW_REWRITE_WORK; + let mut budget = ImageBudget::with_work(cost + 7); + assert!(lzw_to_flate(&s, data.len(), &mut budget).is_some()); + assert_eq!(budget.work_left(), 7); + let mut budget = ImageBudget::with_work(cost); + assert!(lzw_to_flate(&s, data.len(), &mut budget).is_some(), "exactly enough"); + assert_eq!(budget.work_left(), 0); + let mut budget = ImageBudget::with_work(cost - 1); + assert!(lzw_to_flate(&s, data.len(), &mut budget).is_none(), "a step short"); + assert_eq!(budget.work_left(), 0, "and the next stream isn't read"); + assert!(lzw_to_flate(&s, data.len(), &mut budget).is_none()); + } } diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index 3e69e27cb9..ad710f3097 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -5,9 +5,16 @@ use std::sync::Arc; use pdfcraft_cos::{Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full}; use pdfcraft_filters::{Filter, Params}; use pdfcraft_optimize::{ - Compatibility, Compression, ImageSettings, OptimizeError, Settings, SpaceCategory, audit_space, effective_resolutions, optimize, + Compatibility, Compression, ImageSettings, MonoCompression, MonoSettings, OptimizeError, Settings, SpaceCategory, audit_space, + effective_resolutions, optimize, }; +/// What image `s` decodes to, its fax or JBIG2 data included, with no limit on the work: the +/// samples an operation would read through its `ImageBudget`. +fn decoded_image(doc: &Document, s: &Stream) -> Result, pdfcraft_cos::CosError> { + pdfcraft_cos::ImageBudget::with_work(u64::MAX).decode(doc, s, pdfcraft_cos::MAX_DECODED) +} + fn image(doc: &mut Document, w: u32, h: u32, n: usize, jpeg: bool, smask: Option) -> ObjRef { // A smooth gradient with a little texture (photo-like). let mut px = Vec::with_capacity((w * h) as usize * n); @@ -218,6 +225,1988 @@ fn an_identity_decode_array_does_not_stop_reduce() { assert_eq!(stream(&doc, odd).dict.int(b"Width"), Some(900)); } +/// Rows of `w` 1-bit samples (1 white, 0 black), each starting on a byte: lines of text-like +/// glyphs; or, with `noise`, random bits. +fn bilevel(w: u32, h: u32, noise: bool) -> Vec { + let stride = (w as usize).div_ceil(8); + let mut data = vec![0xFF; stride * h as usize]; + let mut seed = 0x9E37_79B9_7F4A_7C15u64; + let mut next = move || { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + seed + }; + let mut black = |x: usize, y: usize| { + if x < w as usize && y < h as usize { + data[y * stride + x / 8] &= !(0x80 >> (x % 8)); + } + }; + if noise { + for y in 0..h as usize { + for x in 0..w as usize { + if next() & 1 == 0 { + black(x, y); + } + } + } + return data; + } + // Lines 24 rows apart, of glyphs drawn from a few shapes (a stem, a bowl, an arch, a dotted + // stem) at any column, 1–3 columns apart with a wider gap between words: text as a scanner + // sees it. Clean, identical rows would favour Flate; a scan's ragged edges favour Group 4. + for line in (4..h as usize).step_by(24) { + let mut x = 8; + while x + 16 < w as usize { + let wide = 5 + (next() % 4) as usize; + let shape = next() % 4; + // Now and then a row of a stroke is a column wider or narrower at an edge, as a scan's + // edges are. + let mut ragged = || match next() % 8 { + 0 => 0, + 1 => 2, + _ => 1, + }; + let mut fill = |x0: usize, x1: usize, y0: usize, y1: usize| { + for y in line + y0..line + y1 { + let (left, right) = ((x + x0 + 1).saturating_sub(ragged()), x + x1 + ragged()); + for xx in left..right.saturating_sub(1) { + black(xx, y); + } + } + }; + match shape { + 0 => fill(0, 2, 0, 14), + 1 => { + fill(0, wide, 4, 6); + fill(0, wide, 12, 14); + fill(0, 2, 4, 14); + fill(wide - 2, wide, 4, 14); + } + 2 => { + fill(0, 2, 4, 14); + fill(wide - 2, wide, 6, 14); + fill(0, wide - 1, 4, 6); + } + _ => { + fill(0, 2, 7, 14); + fill(0, 2, 2, 4); + } + } + x += wide + 2 + (next() % 2) as usize + if next() % 6 == 0 { 7 } else { 0 }; + } + } + data +} + +/// A 1-bit image of `data`, Flate-compressed (with `predictor`, a PNG predictor, if any), with +/// `entries` added to its dictionary. +fn mono_image(doc: &mut Document, w: u32, h: u32, data: &[u8], predictor: Option, entries: &[(&str, Object)]) -> ObjRef { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w as i64)); + d.set(b"Height".to_vec(), Object::Int(h as i64)); + for (k, v) in entries { + d.set(k.as_bytes().to_vec(), v.clone()); + } + let s = match predictor { + None => Stream::flate(d, data), + Some(predictor) => { + let params = pdfcraft_filters::Params { predictor, colors: 1, bits_per_component: 1, columns: w as i64, early_change: 1 }; + let mut parms = Dict::new(); + parms.set(b"Predictor".to_vec(), Object::Int(predictor)); + parms.set(b"BitsPerComponent".to_vec(), Object::Int(1)); + parms.set(b"Columns".to_vec(), Object::Int(w as i64)); + d.set(b"Filter".to_vec(), Object::name("FlateDecode")); + d.set(b"DecodeParms".to_vec(), Object::Dict(parms)); + Stream::from_raw(d, pdfcraft_filters::encode(&pdfcraft_filters::Filter::Flate, ¶ms, data).unwrap()) + } + }; + doc.add(Object::Stream(s)) +} + +fn render(doc: &Document) -> Vec { + let bytes = write_full(doc, &SaveOptions::default()).unwrap(); + let mut r = pdfcraft_render::PageRenderer::new(Arc::new(bytes), pdfcraft_render::RenderConfig::default()); + let out = r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + assert!(out.error.is_none(), "{:?}", out.error); + out.rgba.to_vec() +} + +/// Black-and-white images become CCITT Group 4, losslessly: the page looks the same to the pixel. +/// Grey, an image mask (with a decode array that inverts it), an indexed image of two colours and +/// one stored with a PNG predictor qualify; noise (bigger as G4), a 1-bit RGB image, one whose +/// colour space is a resource name, one wider than Group 4 encodes and a tiny one (bigger as +/// stored) are left alone, as is everything with `mono: Retain`. +#[test] +fn black_and_white_images_become_ccitt_group_4() { + let mut doc = Document::new_empty(); + let (w, h) = (1200, 480); + let text = bilevel(w, h, false); + let gray = mono_image(&mut doc, w, h, &text, None, &[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]); + let decode = Object::Array(vec![1.into(), 0.into()]); + let mask = mono_image(&mut doc, w, h, &text, None, &[("ImageMask", Object::Bool(true)), ("Decode", decode.clone())]); + let indexed_cs = Object::Array(vec![ + Object::name("Indexed"), + Object::name("DeviceRGB"), + 1.into(), + Object::String(pdfcraft_cos::PdfString::literal(vec![0xC0, 0x10, 0x10, 0xF8, 0xF0, 0xE0])), + ]); + let indexed = mono_image(&mut doc, w, h, &text, None, &[("BitsPerComponent", 1.into()), ("ColorSpace", indexed_cs.clone())]); + let predicted = mono_image(&mut doc, w, h, &text, Some(15), &[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]); + let noise = + mono_image(&mut doc, 400, 200, &bilevel(400, 200, true), None, &[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]); + let rgb = mono_image( + &mut doc, + 400, + 160, + &bilevel(1200, 160, false), + None, + &[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceRGB"))], + ); + let wide = mono_image( + &mut doc, + 40_000, + 2, + &bilevel(40_000, 2, false), + None, + &[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))], + ); + // A bare /Indexed is no colour space on its own: it names the page's resource, here RGB, so + // these samples are three to a pixel. + let named = + mono_image(&mut doc, 400, 160, &bilevel(1200, 160, false), None, &[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("Indexed"))]); + // 64 × 8 white: its Group 4 data is smaller than its Flate data, but not by the longer + // dictionary Group 4 needs. + let tiny = mono_image(&mut doc, 64, 8, &[0xFF; 64], None, &[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]); + let tiny_g4 = pdfcraft_filters::encode_g4(&[0xFF; 64], 64, 8).unwrap(); + assert!(tiny_g4.len() < stream(&doc, tiny).raw.len(), "{} against {}", tiny_g4.len(), stream(&doc, tiny).raw.len()); + let p = page( + &mut doc, + &[("G", gray), ("M", mask), ("I", indexed), ("P", predicted), ("N", noise), ("R", rgb), ("W", wide), ("T", tiny), ("X", named)], + "q 300 0 0 120 20 660 cm /G Do Q q 0 0.4 0.8 rg 300 0 0 120 320 660 cm /M Do Q q 300 0 0 120 20 520 cm /I Do Q \ + q 300 0 0 120 320 520 cm /P Do Q q 200 0 0 100 20 400 cm /N Do Q q 200 0 0 80 240 400 cm /R Do Q q 560 0 0 4 20 380 cm /W Do Q q 64 0 0 8 460 400 cm /T Do Q q 200 0 0 80 240 300 cm /X Do Q", + ); + doc.update_dict(p, |d| { + if let Some(Object::Dict(res)) = d.get_mut(b"Resources") { + let mut cs = Dict::new(); + cs.set(b"Indexed".to_vec(), Object::name("DeviceRGB")); + res.set(b"ColorSpace".to_vec(), Object::Dict(cs)); + } + }) + .unwrap(); + let before = render(&doc); + let raw = |doc: &Document, r: ObjRef| stream(doc, r).raw; + let untouched = [noise, rgb, wide, tiny, named].map(|r| raw(&doc, r)); + + let mut kept = doc.clone(); + let r = optimize(&mut kept, &Settings { mono: RETAIN, ..Settings::default() }).unwrap(); + assert_eq!(r.images_recompressed, 0, "Retain keeps every image as it is"); + assert!([gray, mask, indexed, predicted].iter().all(|i| raw(&kept, *i) == raw(&doc, *i))); + + let r = optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!(r.images_recompressed, 4); + assert!(r.image_bytes_after < r.image_bytes_before, "{} → {}", r.image_bytes_before, r.image_bytes_after); + for i in [gray, mask, indexed, predicted] { + let s = stream(&doc, i); + assert_eq!(s.dict.name(b"Filter"), Some(&b"CCITTFaxDecode"[..])); + let parms = s.dict.get(b"DecodeParms").and_then(Object::as_dict).cloned().unwrap(); + assert_eq!((parms.int(b"K"), parms.int(b"Columns"), parms.int(b"Rows")), (Some(-1), Some(w as i64), Some(h as i64))); + } + assert_eq!(stream(&doc, mask).dict.get(b"Decode"), Some(&decode), "the decode array stays"); + assert_eq!(stream(&doc, indexed).dict.get(b"ColorSpace"), Some(&indexed_cs), "the colour space stays"); + assert_eq!([noise, rgb, wide, tiny, named].map(|r| raw(&doc, r)), untouched); + assert!(render(&doc) == before, "the page looks the same"); +} + +/// Which 1-bit images qualify for Group 4: those of one colour component, an ICC profile of one +/// component and a DeviceN of one colourant among them, but not one of three components or two +/// colourants (more than a bit a pixel); image masks of one bit, as `/BitsPerComponent 1` says or +/// by default, but not one that says 8; and images of up to 80 million pixels, not one row more. +/// The byte totals count exactly the images stored again. +#[test] +fn group_4_takes_images_of_one_bit_a_pixel_up_to_the_pixel_limit() { + let mut doc = Document::new_empty(); + let (w, h) = (1200u32, 480u32); + let text = bilevel(w, h, false); + let icc = |doc: &mut Document, n: i64| { + let mut d = Dict::new(); + d.set(b"N".to_vec(), Object::Int(n)); + Object::Array(vec![Object::name("ICCBased"), Object::Ref(doc.add(Object::Stream(Stream::from_raw(d, Vec::new()))))]) + }; + let device_n = |colourants: &[&str]| { + let mut tint = Dict::new(); + tint.set(b"FunctionType".to_vec(), Object::Int(2)); + tint.set(b"Domain".to_vec(), Object::Array(vec![0.into(), 1.into()])); + tint.set(b"N".to_vec(), Object::Int(1)); + Object::Array(vec![ + Object::name("DeviceN"), + Object::Array(colourants.iter().map(|c| Object::name(c)).collect()), + Object::name("DeviceGray"), + Object::Dict(tint), + ]) + }; + let one_bit = |cs: Object| [("BitsPerComponent", 1.into()), ("ColorSpace", cs)]; + let icc_1 = icc(&mut doc, 1); + let icc_1 = mono_image(&mut doc, w, h, &text, None, &one_bit(icc_1)); + let icc_3 = icc(&mut doc, 3); + let icc_3 = mono_image(&mut doc, w, h, &bilevel(3 * w, h, false), None, &one_bit(icc_3)); + let spot = mono_image(&mut doc, w, h, &text, None, &one_bit(device_n(&["Spot"]))); + let two_spots = mono_image(&mut doc, w, h, &bilevel(2 * w, h, false), None, &one_bit(device_n(&["Spot", "Other"]))); + let mask = |bits: i64| [("ImageMask", Object::Bool(true)), ("BitsPerComponent", bits.into())]; + let mask_1 = mono_image(&mut doc, w, h, &text, None, &mask(1)); + let mask_8 = mono_image(&mut doc, w, h, &bilevel(8 * w, h, false), None, &mask(8)); + // 10,000 × 8,000 is the limit itself. + let grey = one_bit(Object::name("DeviceGray")); + let at_limit = mono_image(&mut doc, 10_000, 8_000, &bilevel(10_000, 8_000, false), None, &grey); + let past_limit = mono_image(&mut doc, 10_000, 8_001, &bilevel(10_000, 8_001, false), None, &grey); + let all = [icc_1, icc_3, spot, two_spots, mask_1, mask_8, at_limit, past_limit]; + let names: Vec = (0..all.len()).map(|i| format!("Im{i}")).collect(); + // 200 × 80 pt is 432 ppi, 1800 × 1440 pt 400 ppi: below where monochrome downsampling starts. + let content: String = (0..all.len()) + .map(|i| { + if i < 6 { + format!("q 200 0 0 80 {} {} cm /Im{i} Do Q ", 6 + 200 * (i % 3), 690 - 100 * (i / 3)) + } else { + format!("q 1800 0 0 1440 0 0 cm /Im{i} Do Q ") + } + }) + .collect(); + page(&mut doc, &names.iter().map(String::as_str).zip(all).collect::>(), &content); + let converted = [icc_1, spot, mask_1, at_limit]; + let left = [icc_3, two_spots, mask_8, past_limit]; + let before = |doc: &Document, refs: &[ObjRef]| refs.iter().map(|r| stream(doc, *r).raw).collect::>(); + let (old, untouched) = (before(&doc, &converted), before(&doc, &left)); + let r = optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!(r.images_recompressed, converted.len(), "{r:?}"); + for i in converted { + assert_eq!(stream(&doc, i).dict.name(b"Filter"), Some(&b"CCITTFaxDecode"[..]), "{i:?}"); + } + assert_eq!(before(&doc, &left), untouched); + let new = before(&doc, &converted); + assert_eq!((r.image_bytes_before, r.image_bytes_after), (old.iter().map(|b| b.len()).sum(), new.iter().map(|b| b.len()).sum())); +} + +/// An image whose /DecodeParms are a reference, or hold numbers given by reference +/// (`/Predictor 9 0 R`, which `Dict::int` doesn't follow), is read through them, as one with them +/// direct: a predictor's tag bytes would otherwise shift every row, and the image would be stored +/// again wrong. Here a 1-bit image with a PNG predictor (stored again as Group 4), an eight-bit +/// grey one with a PNG predictor at 600 ppi (resampled), an eight-bit one of only black and white +/// with PNG predictor 10 (each row's tag a 0, so the shifted rows would still be black and white; +/// packed to one bit), and a two-entry indexed one the same way come out byte for byte as with +/// direct parameters. +#[test] +fn images_whose_decode_parms_are_references_come_out_as_with_them_direct() { + let build = |how: &str| { + let mut doc = Document::new_empty(); + let (w, h) = (1200u32, 480u32); + let grey = [("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]; + let one_bit = mono_image(&mut doc, w, h, &bilevel(w, h, false), Some(15), &grey); + let black_and_white_samples = eight_bit(&bilevel(w, h, false), w, h); + let shades: Vec = (0..w * h).map(|i| ((i % w) * 255 / w) as u8 ^ ((i / w) as u8 & 0x0F)).collect(); + let params = pdfcraft_filters::Params { predictor: 15, colors: 1, bits_per_component: 8, columns: w as i64, early_change: 1 }; + let mut parms = Dict::new(); + parms.set(b"Predictor".to_vec(), Object::Int(15)); + parms.set(b"Columns".to_vec(), Object::Int(w as i64)); + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w as i64)); + d.set(b"Height".to_vec(), Object::Int(h as i64)); + d.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + d.set(b"Filter".to_vec(), Object::name("FlateDecode")); + d.set(b"DecodeParms".to_vec(), Object::Dict(parms)); + let coded = pdfcraft_filters::encode(&pdfcraft_filters::Filter::Flate, ¶ms, &shades).unwrap(); + let eight_bit = doc.add(Object::Stream(Stream::from_raw(d.clone(), coded))); + let params = pdfcraft_filters::Params { predictor: 10, ..params }; + let mut parms = Dict::new(); + parms.set(b"Predictor".to_vec(), Object::Int(10)); + parms.set(b"Columns".to_vec(), Object::Int(w as i64)); + d.set(b"DecodeParms".to_vec(), Object::Dict(parms)); + let black_and_white = pdfcraft_filters::encode(&pdfcraft_filters::Filter::Flate, ¶ms, &black_and_white_samples).unwrap(); + let packed = doc.add(Object::Stream(Stream::from_raw(d.clone(), black_and_white))); + let palette = vec![0, 0, 255, 255, 255, 255]; + d.set( + b"ColorSpace".to_vec(), + Object::Array(vec![ + Object::name("Indexed"), + Object::name("DeviceRGB"), + 1.into(), + Object::String(pdfcraft_cos::PdfString::literal(palette)), + ]), + ); + let entries = samples_of(&bilevel(w, h, false), w, h, [&[0], &[1]], 8); + let entries = pdfcraft_filters::encode(&pdfcraft_filters::Filter::Flate, ¶ms, &entries).unwrap(); + let indexed = doc.add(Object::Stream(Stream::from_raw(d, entries))); + for r in [one_bit, eight_bit, packed, indexed] { + let mut s = stream(&doc, r); + let Some(Object::Dict(mut parms)) = s.dict.get(b"DecodeParms").cloned() else { panic!("{:?}", s.dict) }; + let made = match how { + "whole" => Object::Ref(doc.add(Object::Dict(parms))), + "numbers" => { + for k in [&b"Predictor"[..], b"Columns"] { + let v = parms.get(k).cloned().unwrap(); + parms.set(k.to_vec(), Object::Ref(doc.add(v))); + } + Object::Dict(parms) + } + _ => continue, + }; + s.dict.set(b"DecodeParms".to_vec(), made); + doc.set(r, Object::Stream(s)); + } + // 300 × 120 pt is 288 ppi; 144 × 57.6 pt is 600 ppi. + page( + &mut doc, + &[("A", one_bit), ("B", eight_bit), ("C", packed), ("D", indexed)], + "q 300 0 0 120 20 600 cm /A Do Q q 144 0 0 57.6 20 400 cm /B Do Q q 300 0 0 120 20 200 cm /C Do Q \ + q 280 0 0 112 325 200 cm /D Do Q", + ); + let before = [one_bit, eight_bit, packed, indexed].map(|r| stream(&doc, r).raw); + let r = optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!((r.images_resampled, r.images_recompressed), (1, 3), "{r:?}"); + let after = [one_bit, eight_bit, packed, indexed].map(|r| stream(&doc, r)); + assert!(before.iter().zip(&after).all(|(b, a)| *b != a.raw), "all stored again"); + assert_eq!(after[0].dict.name(b"Filter"), Some(&b"CCITTFaxDecode"[..])); + for s in &after[2..] { + assert_eq!((s.dict.int(b"BitsPerComponent"), s.dict.name(b"Filter")), (Some(1), Some(&b"CCITTFaxDecode"[..]))); + } + after.map(|s| s.raw) + }; + let direct = build("direct"); + assert_eq!(build("whole"), direct, "the dictionary by reference"); + assert_eq!(build("numbers"), direct, "the numbers inside by reference"); +} + +/// An image whose data decodes far past what its samples can need (a decompression bomb) is left +/// alone rather than decoded up to the 1 GiB limit: a 1-bit one that would become Group 4, an +/// eight-bit one that would be resampled, and an eight-bit one of only black and white that would +/// be packed to one bit, each with four megabytes of zeros after its samples. Stored as ASCIIHex +/// text under Flate, broken into lines of 64 digits, their samples aren't a bomb. +#[test] +fn images_whose_data_decodes_far_past_their_samples_are_left_alone() { + let (w, h) = (1200u32, 480u32); + let text = bilevel(w, h, false); + let shades: Vec = (0..w * h).map(|i| ((i % w) * 255 / w) as u8 ^ ((i / w) as u8 & 0x0F)).collect(); + for padding in [0, 4 << 20] { + let mut doc = Document::new_empty(); + let padded = |data: &[u8]| data.iter().copied().chain(std::iter::repeat_n(0, padding)).collect::>(); + let grey = |bits: i64| [("BitsPerComponent", bits.into()), ("ColorSpace", Object::name("DeviceGray"))]; + let one_bit = mono_image(&mut doc, w, h, &padded(&text), None, &grey(1)); + let eight_bit = mono_image(&mut doc, w, h, &padded(&shades), None, &grey(8)); + let packed = mono_image(&mut doc, w, h, &padded(&self::eight_bit(&text, w, h)), None, &grey(8)); + // 300 × 120 pt is 288 ppi; 144 × 57.6 pt is 600 ppi. + page( + &mut doc, + &[("A", one_bit), ("B", eight_bit), ("C", packed)], + "q 300 0 0 120 20 600 cm /A Do Q q 144 0 0 57.6 20 400 cm /B Do Q q 300 0 0 120 20 200 cm /C Do Q", + ); + let before = [one_bit, eight_bit, packed].map(|r| stream(&doc, r).raw); + let r = optimize(&mut doc, &Settings::default()).unwrap(); + let after = [one_bit, eight_bit, packed].map(|r| stream(&doc, r).raw); + if padding == 0 { + assert_eq!((r.images_resampled, r.images_recompressed), (1, 2), "{r:?}"); + } else { + assert_eq!((r.images_resampled, r.images_recompressed), (0, 0), "{r:?}"); + assert_eq!(after, before); + } + } + let hex_lines = |data: &[u8]| { + let digits: Vec = data.iter().flat_map(|b| format!("{b:02X}").into_bytes()).collect(); + let mut text: Vec = digits.chunks(64).flat_map(|line| line.iter().copied().chain(*b"\n")).collect(); + text.push(b'>'); + pdfcraft_filters::encode_flate(&text) + }; + let mut doc = Document::new_empty(); + let as_hex = |doc: &mut Document, data: &[u8], bits: i64, (w, h): (u32, u32)| { + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w.into())); + d.set(b"Height".to_vec(), Object::Int(h.into())); + d.set(b"BitsPerComponent".to_vec(), Object::Int(bits)); + d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + d.set(b"Filter".to_vec(), Object::Array(vec![Object::name("FlateDecode"), Object::name("ASCIIHexDecode")])); + doc.add(Object::Stream(Stream::from_raw(d, hex_lines(data)))) + }; + // Large enough that its hex text in lines is more than twice its samples and a byte a row + // plus 4 KiB: 2560 × 1200, drawn at 400 ppi. + let page_scan = bilevel(2560, 1200, false); + let one_bit = as_hex(&mut doc, &page_scan, 1, (2560, 1200)); + let eight_bit = as_hex(&mut doc, &shades, 8, (w, h)); + let packed = as_hex(&mut doc, &self::eight_bit(&text, w, h), 8, (w, h)); + page( + &mut doc, + &[("A", one_bit), ("B", eight_bit), ("C", packed)], + "q 460.8 0 0 216 20 500 cm /A Do Q q 144 0 0 57.6 20 400 cm /B Do Q q 300 0 0 120 20 200 cm /C Do Q", + ); + optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!(stream(&doc, one_bit).dict.name(b"Filter"), Some(&b"CCITTFaxDecode"[..]), "1-bit hex text in lines: Group 4"); + assert!(stream(&doc, eight_bit).dict.int(b"Width").is_some_and(|nw| nw < i64::from(w)), "8-bit hex text in lines: resampled"); + assert_eq!(stream(&doc, packed).dict.int(b"BitsPerComponent"), Some(1), "8-bit black-and-white hex text in lines: packed"); +} + +/// Monochrome Images keeping each image's compression (and resampling as by default). +const RETAIN: MonoSettings = MonoSettings { compression: MonoCompression::Retain, ..MonoSettings::REDUCE }; + +/// Eight-bit samples (0 or 255) from rows of one-bit ones. +fn eight_bit(bits: &[u8], w: u32, h: u32) -> Vec { + let stride = (w as usize).div_ceil(8); + (0..h as usize).flat_map(|y| (0..w as usize).map(move |x| if bits[y * stride + x / 8] & (0x80 >> (x % 8)) != 0 { 255 } else { 0 })).collect() +} + +/// Eight-bit samples of only 0 and 255, dithered (Floyd–Steinberg) from a smooth picture as a +/// scanner's halftone mode gives them: no shade but black and white, in short runs. +fn dithered(w: u32, h: u32) -> Vec { + let (w, h) = (w as usize, h as usize); + let mut level: Vec = (0..w * h).map(|i| 127.5 + 127.5 * ((i % w) as f32 / 37.0).sin() * ((i / w) as f32 / 53.0).cos()).collect(); + let mut out = vec![0u8; w * h]; + for y in 0..h { + for x in 0..w { + let i = y * w + x; + let v = if level[i] < 128.0 { 0.0 } else { 255.0 }; + out[i] = v as u8; + let e = level[i] - v; + if x + 1 < w { + level[i + 1] += e * 7.0 / 16.0; + } + if y + 1 < h { + if x > 0 { + level[i + w - 1] += e * 3.0 / 16.0; + } + level[i + w] += e * 5.0 / 16.0; + if x + 1 < w { + level[i + w + 1] += e / 16.0; + } + } + } + } + out +} + +/// A dithered scan stored as eight-bit grey of only black and white is smaller downsampled and +/// JPEG-compressed by the grey settings than packed to one bit, which can't shrink its dots: the +/// smaller wins, so Reduce File Size makes it no larger than with Monochrome Images on Retain. +/// Clean text is still packed to one bit at the same resolution. +#[test] +fn eight_bit_black_and_white_takes_the_smaller_of_one_bit_and_the_grey_settings() { + let (w, h) = (1200u32, 1500u32); + for (what, samples) in [("dither", dithered(w, h)), ("text", eight_bit(&bilevel(w, h, false), w, h))] { + let build = || { + let mut doc = Document::new_empty(); + let img = mono_image(&mut doc, w, h, &samples, None, &[("BitsPerComponent", 8.into()), ("ColorSpace", Object::name("DeviceGray"))]); + // 288 × 360 pt is 300 ppi, above where the grey settings downsample. + page(&mut doc, &[("A", img)], "q 288 0 0 360 20 400 cm /A Do Q"); + (doc, img) + }; + let (mut doc, img) = build(); + optimize(&mut doc, &Settings::default()).unwrap(); + let (mut retained, _) = build(); + optimize(&mut retained, &Settings { mono: RETAIN, ..Settings::default() }).unwrap(); + let (s, kept) = (stream(&doc, img), stream(&retained, img)); + let bits = if what == "dither" { 8 } else { 1 }; + assert_eq!(s.dict.int(b"BitsPerComponent"), Some(bits), "{what}: {} bytes, {} with Retain", s.raw.len(), kept.raw.len()); + if what == "dither" { + assert!(s.raw.len() <= kept.raw.len(), "{what}: {} bytes, {} with Retain", s.raw.len(), kept.raw.len()); + } + } +} + +/// The smaller of one bit and the grey settings counts the soft mask each leaves: packed, the +/// image keeps its mask as it is; resampled by the grey settings, the mask is resampled with it. +/// Clean text, 1200 × 1500 at 300 ppi, under a noisy mask. A mask as large: the grey settings +/// shrink it by far more than packing saves on the image, and win. A mask of 640 × 800 (a soft +/// mask may be another size than its image): resampled to the image's new size it shrinks only a +/// little, by less than the grey settings' JPEG adds over the packed image, and packing wins. +#[test] +fn eight_bit_black_and_white_with_a_soft_mask_counts_the_mask() { + let (w, h) = (1200u32, 1500u32); + for ((mw, mh), grey_wins) in [((w, h), true), ((640, 800), false)] { + let mut doc = Document::new_empty(); + let mut seed = 0x2545_F491_4F6C_DD1Du64; + let noise: Vec = (0..mw * mh) + .map(|_| { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + (seed >> 24) as u8 + }) + .collect(); + let mask = mono_image(&mut doc, mw, mh, &noise, None, &[("BitsPerComponent", 8.into()), ("ColorSpace", Object::name("DeviceGray"))]); + let grey = [("BitsPerComponent", 8.into()), ("ColorSpace", Object::name("DeviceGray")), ("SMask", Object::Ref(mask))]; + let img = mono_image(&mut doc, w, h, &eight_bit(&bilevel(w, h, false), w, h), None, &grey); + page(&mut doc, &[("A", img)], "q 288 0 0 360 20 400 cm /A Do Q"); + optimize(&mut doc, &Settings::default()).unwrap(); + let s = stream(&doc, img); + let expected = if grey_wins { (Some(8), Some(&b"DCTDecode"[..])) } else { (Some(1), Some(&b"CCITTFaxDecode"[..])) }; + assert_eq!((s.dict.int(b"BitsPerComponent"), s.dict.name(b"Filter")), expected, "mask {mw} × {mh}: {:?}", s.dict); + } +} + +/// Eight-bit grey images of only black and white (as some scanners and converters write scans) +/// become one-bit Group 4, losslessly: the page looks the same, an inverting decode array +/// included. Not touched by this: a grey image with any other shade, an indexed image (its samples +/// are palette indices), one masked by colour key, and everything with `mono: Retain`. +#[test] +fn eight_bit_black_and_white_images_become_one_bit() { + let mut doc = Document::new_empty(); + let (w, h) = (1200, 480); + let grey = eight_bit(&bilevel(w, h, false), w, h); + let image = |doc: &mut Document, data: &[u8], entries: &[(&str, Object)]| { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w as i64)); + d.set(b"Height".to_vec(), Object::Int(h as i64)); + d.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + for (k, v) in entries { + d.set(k.as_bytes().to_vec(), v.clone()); + } + doc.add(Object::Stream(Stream::flate(d, data))) + }; + let gray_cs = ("ColorSpace", Object::name("DeviceGray")); + let plain = image(&mut doc, &grey, std::slice::from_ref(&gray_cs)); + let decode = Object::Array(vec![1.into(), 0.into()]); + let inverted = image(&mut doc, &grey, &[gray_cs.clone(), ("Decode", decode.clone())]); + let mut shaded = grey.clone(); + shaded[1000] = 128; + let shade = image(&mut doc, &shaded, std::slice::from_ref(&gray_cs)); + let palette = Object::Array(vec![ + Object::name("Indexed"), + Object::name("DeviceGray"), + 255.into(), + Object::String(pdfcraft_cos::PdfString::literal((0..=255u8).rev().collect::>())), + ]); + let indexed = image(&mut doc, &grey, &[("ColorSpace", palette)]); + let keyed = image(&mut doc, &grey, &[gray_cs, ("Mask", Object::Array(vec![0.into(), 0.into()]))]); + page( + &mut doc, + &[("A", plain), ("B", inverted), ("C", shade), ("D", indexed), ("E", keyed)], + "q 300 0 0 120 20 660 cm /A Do Q q 300 0 0 120 320 660 cm /B Do Q q 300 0 0 120 20 520 cm /C Do Q \ + q 300 0 0 120 320 520 cm /D Do Q q 300 0 0 120 20 380 cm /E Do Q", + ); + let before = render(&doc); + let raw = |doc: &Document, r: ObjRef| stream(doc, r).raw; + // Grey settings that leave grey images alone, so only this conversion acts. + let keep = ImageSettings { downsample: false, target_ppi: 150.0, above_ppi: 225.0, compression: Compression::Retain }; + let settings = Settings { gray: keep, ..Settings::default() }; + + let mut kept = doc.clone(); + let r = optimize(&mut kept, &Settings { mono: RETAIN, ..settings.clone() }).unwrap(); + assert_eq!(r.images_recompressed, 0, "Retain keeps every image as it is"); + + let untouched = [shade, indexed, keyed].map(|r| raw(&doc, r)); + let old: usize = [plain, inverted].map(|r| raw(&doc, r).len()).iter().sum(); + let r = optimize(&mut doc, &settings).unwrap(); + assert_eq!(r.images_recompressed, 2); + let new: usize = [plain, inverted].map(|r| raw(&doc, r).len()).iter().sum(); + assert_eq!((r.image_bytes_before, r.image_bytes_after), (old, new), "the byte totals count both"); + for i in [plain, inverted] { + let s = stream(&doc, i); + assert_eq!((s.dict.int(b"BitsPerComponent"), s.dict.name(b"Filter")), (Some(1), Some(&b"CCITTFaxDecode"[..]))); + } + assert_eq!(stream(&doc, inverted).dict.get(b"Decode"), Some(&decode), "the decode array stays"); + assert_eq!([shade, indexed, keyed].map(|r| raw(&doc, r)), untouched); + assert!(render(&doc) == before, "the page looks the same"); +} + +/// `bits` (1-bit rows) with each 0 as `values[0]` and each 1 as `values[1]`, at `bpc` bits a sample +/// (each row starting on a new byte). +fn samples_of(bits: &[u8], w: u32, h: u32, values: [&[u8]; 2], bpc: usize) -> Vec { + let (w, h) = (w as usize, h as usize); + let stride = w.div_ceil(8); + let mut out = Vec::new(); + for y in 0..h { + let row: Vec = (0..w).flat_map(|x| values[usize::from(bits[y * stride + x / 8] & (0x80 >> (x % 8)) != 0)].iter().copied()).collect(); + let mut packed = vec![0u8; (row.len() * bpc).div_ceil(8)]; + for (i, s) in row.iter().enumerate() { + packed[i * bpc / 8] |= if bpc == 8 { *s } else { s << (8 - bpc - i * bpc % 8) }; + } + out.extend(packed); + } + out +} + +/// Eight-bit RGB of only black and white becomes one-bit grey, and an indexed image that uses only +/// palette entries 0 and 1 becomes one bit with its palette (here blue and white), at 8 bits or 4, +/// both as Group 4 and losslessly: the page looks the same to the pixel. An RGB decode array the +/// same for each component becomes grey's, and an indexed image's default one goes. Not touched: +/// RGB with any other colour, a decode array that differs between components, a colour-key mask or +/// calibrated RGB; an indexed image that uses a third entry or has another decode array; and +/// everything with `mono: Retain`. +#[test] +fn rgb_and_two_colour_indexed_black_and_white_images_become_one_bit() { + let mut doc = Document::new_empty(); + let (w, h) = (1200u32, 480u32); + let text = bilevel(w, h, false); + let image = |doc: &mut Document, data: &[u8], bpc: i64, cs: Object, entries: &[(&str, Object)]| { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w.into())); + d.set(b"Height".to_vec(), Object::Int(h.into())); + d.set(b"BitsPerComponent".to_vec(), Object::Int(bpc)); + d.set(b"ColorSpace".to_vec(), cs); + for (k, v) in entries { + d.set(k.as_bytes().to_vec(), v.clone()); + } + doc.add(Object::Stream(Stream::flate(d, data))) + }; + let rgb_cs = Object::name("DeviceRGB"); + let rgb_data = samples_of(&text, w, h, [&[0, 0, 0], &[255, 255, 255]], 8); + let rgb = image(&mut doc, &rgb_data, 8, rgb_cs.clone(), &[]); + let pairs = |p: [i64; 6]| Object::Array(p.into_iter().map(Object::Int).collect()); + let rgb_inverted = image(&mut doc, &rgb_data, 8, rgb_cs.clone(), &[("Decode", pairs([1, 0, 1, 0, 1, 0]))]); + let rgb_mixed = image(&mut doc, &rgb_data, 8, rgb_cs.clone(), &[("Decode", pairs([1, 0, 0, 1, 0, 1]))]); + let mut red = rgb_data.clone(); + red[3000..3003].copy_from_slice(&[255, 0, 0]); + let rgb_red = image(&mut doc, &red, 8, rgb_cs.clone(), &[]); + let mut white_point = Dict::new(); + white_point.set(b"WhitePoint".to_vec(), Object::Array(vec![Object::Real(0.9505), 1.into(), Object::Real(1.089)])); + let cal_rgb = image(&mut doc, &rgb_data, 8, Object::Array(vec![Object::name("CalRGB"), Object::Dict(white_point)]), &[]); + let rgb_keyed = image(&mut doc, &rgb_data, 8, rgb_cs, &[("Mask", pairs([0, 0, 0, 0, 0, 0]))]); + let palette = |entries: &[u8]| { + Object::Array(vec![ + Object::name("Indexed"), + Object::name("DeviceRGB"), + (entries.len() as i64 / 3 - 1).into(), + Object::String(pdfcraft_cos::PdfString::literal(entries.to_vec())), + ]) + }; + let blue_white = palette(&[0, 0, 255, 255, 255, 255]); + let indices = samples_of(&text, w, h, [&[0], &[1]], 8); + let blue = image(&mut doc, &indices, 8, blue_white.clone(), &[]); + let default_decode = Object::Array(vec![0.into(), 255.into()]); + let blue_decoded = image(&mut doc, &indices, 8, blue_white.clone(), &[("Decode", default_decode)]); + let blue_4 = image(&mut doc, &samples_of(&text, w, h, [&[0], &[1]], 4), 4, blue_white.clone(), &[]); + let mut third = indices.clone(); + third[3000] = 2; + let three = image(&mut doc, &third, 8, palette(&[0, 0, 255, 255, 255, 255, 255, 0, 0]), &[]); + let other_decode = image(&mut doc, &indices, 8, blue_white.clone(), &[("Decode", Object::Array(vec![0.into(), 1.into()]))]); + let all = [rgb, rgb_inverted, rgb_mixed, rgb_red, cal_rgb, rgb_keyed, blue, blue_decoded, blue_4, three, other_decode]; + // 200 × 80 pt is 432 ppi: below where monochrome downsampling starts. + let names: Vec = (0..all.len()).map(|i| format!("Im{i}")).collect(); + let content: String = + (0..all.len()).map(|i| format!("q 200 0 0 80 {} {} cm /Im{i} Do Q ", 6 + 200 * (i % 3), 690 - 100 * (i / 3) as i64)).collect(); + page(&mut doc, &names.iter().map(String::as_str).zip(all).collect::>(), &content); + let before = render(&doc); + let raw = |doc: &Document, r: ObjRef| stream(doc, r).raw; + // Colour and grey settings that leave images alone, so only this conversion acts. + let keep = ImageSettings { downsample: false, target_ppi: 150.0, above_ppi: 225.0, compression: Compression::Retain }; + let settings = Settings { color: keep, gray: keep, ..Settings::default() }; + + let mut kept = doc.clone(); + let r = optimize(&mut kept, &Settings { mono: RETAIN, ..settings.clone() }).unwrap(); + assert_eq!((r.images_resampled, r.images_recompressed), (0, 0), "Retain keeps every image as it is"); + + let untouched = [rgb_mixed, rgb_red, cal_rgb, rgb_keyed, three, other_decode].map(|r| raw(&doc, r)); + let r = optimize(&mut doc, &settings).unwrap(); + assert_eq!((r.images_resampled, r.images_recompressed), (0, 5), "{r:?}"); + for (i, cs) in [ + (rgb, Object::name("DeviceGray")), + (rgb_inverted, Object::name("DeviceGray")), + (blue, blue_white.clone()), + (blue_decoded, blue_white.clone()), + (blue_4, blue_white), + ] { + let s = stream(&doc, i); + assert_eq!( + (s.dict.int(b"BitsPerComponent"), s.dict.name(b"Filter"), s.dict.get(b"ColorSpace")), + (Some(1), Some(&b"CCITTFaxDecode"[..]), Some(&cs)), + "{i:?}" + ); + assert_eq!(decoded_image(&doc, &s).unwrap(), text, "{i:?}"); + let decode = if i == rgb_inverted { Some(Object::Array(vec![1.into(), 0.into()])) } else { None }; + assert_eq!(s.dict.get(b"Decode").cloned(), decode, "{i:?}"); + } + assert_eq!([rgb_mixed, rgb_red, cal_rgb, rgb_keyed, three, other_decode].map(|r| raw(&doc, r)), untouched); + assert!(render(&doc) == before, "the page looks the same"); +} + +/// Eight-bit black-and-white images in other one-component spaces are packed too: calibrated +/// grey, an ICC profile of one component, a separation, each keeping its colour space. Calibrated +/// RGB whose samples are only 0 and 1 is no indexed image, and stays as it is. +#[test] +fn eight_bit_black_and_white_in_other_one_component_spaces_is_packed() { + let mut doc = Document::new_empty(); + let (w, h) = (1200u32, 480u32); + let text = bilevel(w, h, false); + let image = |doc: &mut Document, data: &[u8], cs: Object| { + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w.into())); + d.set(b"Height".to_vec(), Object::Int(h.into())); + d.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + d.set(b"ColorSpace".to_vec(), cs); + doc.add(Object::Stream(Stream::flate(d, data))) + }; + let mut white_point = Dict::new(); + white_point.set(b"WhitePoint".to_vec(), Object::Array(vec![Object::Real(0.9505), 1.into(), Object::Real(1.089)])); + let mut profile = Dict::new(); + profile.set(b"N".to_vec(), Object::Int(1)); + let profile = doc.add(Object::Stream(Stream::from_raw(profile, Vec::new()))); + let mut tint = Dict::new(); + tint.set(b"FunctionType".to_vec(), Object::Int(2)); + tint.set(b"Domain".to_vec(), Object::Array(vec![0.into(), 1.into()])); + tint.set(b"N".to_vec(), Object::Int(1)); + let spaces = [ + Object::Array(vec![Object::name("CalGray"), Object::Dict(white_point.clone())]), + Object::Array(vec![Object::name("ICCBased"), Object::Ref(profile)]), + Object::Array(vec![Object::name("Separation"), Object::name("Spot"), Object::name("DeviceGray"), Object::Dict(tint)]), + ]; + let grey = samples_of(&text, w, h, [&[0], &[255]], 8); + let packed = spaces.clone().map(|cs| image(&mut doc, &grey, cs)); + let cal_rgb = Object::Array(vec![Object::name("CalRGB"), Object::Dict(white_point)]); + let cal_rgb = image(&mut doc, &samples_of(&text, w, h, [&[0, 0, 0], &[1, 1, 1]], 8), cal_rgb); + let names = ["A", "B", "C", "D"]; + // 200 × 80 pt is 432 ppi: below where monochrome downsampling starts. + let content: String = (0..4).map(|i| format!("q 200 0 0 80 6 {} cm /{} Do Q ", 690 - 100 * i, names[i])).collect(); + page(&mut doc, &names.into_iter().zip([packed[0], packed[1], packed[2], cal_rgb]).collect::>(), &content); + let untouched = stream(&doc, cal_rgb).raw; + // Colour and grey settings that leave images alone, so only the packing acts. + let keep = ImageSettings { downsample: false, target_ppi: 150.0, above_ppi: 225.0, compression: Compression::Retain }; + optimize(&mut doc, &Settings { color: keep, gray: keep, ..Settings::default() }).unwrap(); + for (r, cs) in packed.iter().zip(&spaces) { + let s = stream(&doc, *r); + assert_eq!((s.dict.int(b"BitsPerComponent"), s.dict.get(b"ColorSpace")), (Some(1), Some(cs)), "{cs:?}"); + } + assert_eq!(stream(&doc, cal_rgb).raw, untouched, "calibrated RGB"); +} + +/// A resampled Group 4 image is stored as 1-bit Flate when that is smaller, as any resampled image +/// may be: here vertical stripes, whose identical rows Flate takes for almost nothing. +#[test] +fn a_resampled_group_4_image_is_stored_as_flate_when_smaller() { + let (w, h) = (1200u32, 480u32); + let stripes = vec![0x0Fu8; (w as usize).div_ceil(8) * h as usize]; + let mut doc = Document::new_empty(); + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w.into())); + d.set(b"Height".to_vec(), Object::Int(h.into())); + d.set(b"BitsPerComponent".to_vec(), Object::Int(1)); + d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + d.set(b"Filter".to_vec(), Object::name("CCITTFaxDecode")); + let mut parms = Dict::new(); + parms.set(b"K".to_vec(), Object::Int(-1)); + parms.set(b"Columns".to_vec(), Object::Int(w.into())); + d.set(b"DecodeParms".to_vec(), Object::Dict(parms)); + let img = doc.add(Object::Stream(Stream::from_raw(d, pdfcraft_filters::encode_g4(&stripes, w, h).unwrap()))); + // 144 × 57.6 pt is 600 ppi. + page(&mut doc, &[("A", img)], "q 144 0 0 57.6 20 600 cm /A Do Q"); + let r = optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!(r.images_resampled, 1, "{r:?}"); + let s = stream(&doc, img); + assert_eq!((s.dict.int(b"Width"), s.dict.name(b"Filter")), (Some(600), Some(&b"FlateDecode"[..]))); +} + +/// A decode array is read as numbers, whichever way they are written: an indexed image's default +/// as `[0 255.0]` or with an entry by reference, and RGB's inverting pairs with a real among them, +/// pack as when written as integers. +#[test] +fn decode_arrays_read_as_numbers_however_written() { + let mut doc = Document::new_empty(); + let (w, h) = (1200u32, 480u32); + let text = bilevel(w, h, false); + let image = |doc: &mut Document, data: &[u8], cs: Object, decode: Object| { + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w.into())); + d.set(b"Height".to_vec(), Object::Int(h.into())); + d.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + d.set(b"ColorSpace".to_vec(), cs); + d.set(b"Decode".to_vec(), decode); + doc.add(Object::Stream(Stream::flate(d, data))) + }; + let palette = Object::Array(vec![ + Object::name("Indexed"), + Object::name("DeviceRGB"), + 1.into(), + Object::String(pdfcraft_cos::PdfString::literal(vec![0, 0, 255, 255, 255, 255])), + ]); + let indices = samples_of(&text, w, h, [&[0], &[1]], 8); + let max = doc.add(Object::Int(255)); + let real = image(&mut doc, &indices, palette.clone(), Object::Array(vec![0.into(), Object::Real(255.0)])); + let referred = image(&mut doc, &indices, palette, Object::Array(vec![0.into(), Object::Ref(max)])); + let rgb = samples_of(&text, w, h, [&[0, 0, 0], &[255, 255, 255]], 8); + let pairs = Object::Array(vec![1.into(), 0.into(), 1.into(), 0.into(), Object::Real(1.0), 0.into()]); + let inverted = image(&mut doc, &rgb, Object::name("DeviceRGB"), pairs); + page( + &mut doc, + &[("A", real), ("B", referred), ("C", inverted)], + "q 200 0 0 80 6 690 cm /A Do Q q 200 0 0 80 6 590 cm /B Do Q q 200 0 0 80 6 490 cm /C Do Q", + ); + let keep = ImageSettings { downsample: false, target_ppi: 150.0, above_ppi: 225.0, compression: Compression::Retain }; + optimize(&mut doc, &Settings { color: keep, gray: keep, ..Settings::default() }).unwrap(); + for r in [real, referred] { + let s = stream(&doc, r); + assert_eq!((s.dict.int(b"BitsPerComponent"), s.dict.get(b"Decode")), (Some(1), None), "{r:?}"); + } + let s = stream(&doc, inverted); + let decode = Object::Array(vec![1.into(), 0.into()]); + assert_eq!((s.dict.int(b"BitsPerComponent"), s.dict.get(b"Decode")), (Some(1), Some(&decode))); +} + +/// With Retain, a Group 3 halftone above 450 ppi, which isn't resampled, stays Group 3 byte for +/// byte: Retain stores nothing again that it doesn't resample. +#[test] +fn retain_keeps_a_group_3_halftone_as_it_is() { + let (w, h) = (64u32, 64u32); + let a: Vec = vec![1; 64]; + let b: Vec = std::iter::once(0).chain(std::iter::repeat_n(1, 64)).collect(); + let runs: Vec<&[u32]> = (0..h).map(|y| if y % 2 == 0 { &a[..] } else { &b[..] }).collect(); + let g3 = group_3(&runs, false, None); + let mut doc = Document::new_empty(); + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w.into())); + d.set(b"Height".to_vec(), Object::Int(h.into())); + d.set(b"BitsPerComponent".to_vec(), Object::Int(1)); + d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + d.set(b"Filter".to_vec(), Object::name("CCITTFaxDecode")); + let mut parms = Dict::new(); + parms.set(b"K".to_vec(), Object::Int(0)); + parms.set(b"Columns".to_vec(), Object::Int(w.into())); + d.set(b"DecodeParms".to_vec(), Object::Dict(parms)); + let fax = doc.add(Object::Stream(Stream::from_raw(d, g3.clone()))); + // 7.68 pt for 64 samples is 600 ppi. + page(&mut doc, &[("A", fax)], "q 7.68 0 0 7.68 100 600 cm /A Do Q"); + assert_eq!(decoded_image(&doc, &stream(&doc, fax)).unwrap().len(), 64 * 8, "the fixture decodes"); + let r = optimize(&mut doc, &Settings { mono: RETAIN, ..Settings::default() }).unwrap(); + assert_eq!((r.images_resampled, r.images_recompressed), (0, 0), "{r:?}"); + assert_eq!(stream(&doc, fax).raw[..], g3[..]); +} + +/// Black-and-white RGB stays RGB where grey would be drawn otherwise: drawn, on any of its uses, +/// where `/DefaultGray` or `/DefaultRGB` is in force (ISO 32000-2 §8.6.5.6), from a page's +/// resources or a form's own, as grey would then go through DefaultGray, or no longer through +/// DefaultRGB; or with a soft mask whose `/Matte` has three components, one for each of RGB's. A +/// form drawn where they are in force counts too, though it has resources of its own. The same +/// image drawn plainly, or with a soft mask and no `/Matte`, becomes grey. +#[test] +fn black_and_white_rgb_stays_rgb_where_grey_would_be_drawn_otherwise() { + let mut doc = Document::new_empty(); + let (w, h) = (1200u32, 480u32); + let rgb_data = samples_of(&bilevel(w, h, false), w, h, [&[0, 0, 0], &[255, 255, 255]], 8); + let image = |doc: &mut Document, cs: &str, data: &[u8], entries: &[(&str, Object)]| { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w.into())); + d.set(b"Height".to_vec(), Object::Int(h.into())); + d.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + d.set(b"ColorSpace".to_vec(), Object::name(cs)); + for (k, v) in entries { + d.set(k.as_bytes().to_vec(), v.clone()); + } + doc.add(Object::Stream(Stream::flate(d, data))) + }; + let opaque = vec![255u8; (w * h) as usize]; + let plain_mask = image(&mut doc, "DeviceGray", &opaque, &[]); + let matte_mask = image(&mut doc, "DeviceGray", &opaque, &[("Matte", Object::Array(vec![0.into(); 3]))]); + let plain = image(&mut doc, "DeviceRGB", &rgb_data, &[]); + let masked = image(&mut doc, "DeviceRGB", &rgb_data, &[("SMask", Object::Ref(plain_mask))]); + let matted = image(&mut doc, "DeviceRGB", &rgb_data, &[("SMask", Object::Ref(matte_mask))]); + let [under_gray, under_rgb, shared, in_form, nested] = [(); 5].map(|_| image(&mut doc, "DeviceRGB", &rgb_data, &[])); + let mut white_point = Dict::new(); + white_point.set(b"WhitePoint".to_vec(), Object::Array(vec![Object::Real(0.9505), 1.into(), Object::Real(1.089)])); + let spaces = |key: &str| { + let family = if key == "DefaultGray" { "CalGray" } else { "CalRGB" }; + let mut c = Dict::new(); + c.set(key.as_bytes().to_vec(), Object::Array(vec![Object::name(family), Object::Dict(white_point.clone())])); + Object::Dict(c) + }; + let draw = |names: &[&str]| names.iter().enumerate().map(|(i, n)| format!("q 200 0 0 80 6 {} cm /{n} Do Q ", 690 - 100 * i)).collect::(); + let form = |doc: &mut Document, im: ObjRef, spaces: Option| { + let mut xobjects = Dict::new(); + xobjects.set(b"Im".to_vec(), Object::Ref(im)); + let mut resources = Dict::new(); + resources.set(b"XObject".to_vec(), Object::Dict(xobjects)); + if let Some(spaces) = spaces { + resources.set(b"ColorSpace".to_vec(), spaces); + } + let mut f = Dict::new(); + f.set(b"Type".to_vec(), Object::name("XObject")); + f.set(b"Subtype".to_vec(), Object::name("Form")); + f.set(b"BBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 612.into(), 792.into()])); + f.set(b"Resources".to_vec(), Object::Dict(resources)); + doc.add(Object::Stream(Stream::from_raw(f, draw(&["Im"]).into_bytes()))) + }; + // Drawn plainly first, then where DefaultGray is in force: the second walk must happen. + let plain_form = form(&mut doc, nested, None); + page(&mut doc, &[("A", plain), ("B", masked), ("C", matted), ("D", shared), ("E", plain_form)], &draw(&["A", "B", "C", "D", "E"])); + for (key, xobjects) in [("DefaultGray", vec![("A", under_gray), ("B", shared), ("C", plain_form)]), ("DefaultRGB", vec![("A", under_rgb)])] { + let names: Vec<&str> = xobjects.iter().map(|(n, _)| *n).collect(); + let p = page(&mut doc, &xobjects, &draw(&names)); + doc.update_dict(p, |d| { + let mut res = d.get(b"Resources").and_then(Object::as_dict).cloned().unwrap(); + res.set(b"ColorSpace".to_vec(), spaces(key)); + d.set(b"Resources".to_vec(), Object::Dict(res)); + }) + .unwrap(); + } + // A form with its own resources, DefaultGray among them, on a page without any. + let gray_form = form(&mut doc, in_form, Some(spaces("DefaultGray"))); + page(&mut doc, &[("F", gray_form)], "/F Do"); + let kept = [under_gray, under_rgb, shared, in_form, nested, matted]; + let before = kept.map(|r| stream(&doc, r)); + // Colour and grey settings that leave images alone, so only this conversion acts. + let keep = ImageSettings { downsample: false, target_ppi: 150.0, above_ppi: 225.0, compression: Compression::Retain }; + let settings = Settings { color: keep, gray: keep, ..Settings::default() }; + let r = optimize(&mut doc, &settings).unwrap(); + assert_eq!((r.images_resampled, r.images_recompressed), (0, 2), "{r:?}"); + for i in [plain, masked] { + let s = stream(&doc, i); + assert_eq!((s.dict.get(b"ColorSpace"), s.dict.int(b"BitsPerComponent")), (Some(&Object::name("DeviceGray")), Some(1)), "{i:?}"); + } + for (i, b) in kept.iter().zip(&before) { + let s = stream(&doc, *i); + assert_eq!((&s.dict, &s.raw), (&b.dict, &b.raw), "{i:?}"); + } + + // A use that couldn't be followed (forms nested too deep) may be where DefaultGray is in force. + let mut doc = Document::new_empty(); + let img = image(&mut doc, "DeviceRGB", &rgb_data, &[]); + let deep = nested_forms(&mut doc, img, 13); + page(&mut doc, &[("Im", img), ("F", deep)], &draw(&["Im", "F"])); + let before = stream(&doc, img); + optimize(&mut doc, &settings).unwrap(); + assert_eq!(stream(&doc, img).raw, before.raw, "a use not followed"); +} + +/// An empty document, and a maker of images of only black and white as eight-bit `DeviceRGB`. +fn rgb_page_fixture() -> (Document, impl Fn(&mut Document) -> ObjRef) { + let (w, h) = (1200u32, 480u32); + let rgb_data = samples_of(&bilevel(w, h, false), w, h, [&[0, 0, 0], &[255, 255, 255]], 8); + let image = move |doc: &mut Document| { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w.into())); + d.set(b"Height".to_vec(), Object::Int(h.into())); + d.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + d.set(b"ColorSpace".to_vec(), Object::name("DeviceRGB")); + doc.add(Object::Stream(Stream::flate(d, &rgb_data))) + }; + (Document::new_empty(), image) +} + +/// `q … cm /N Do Q` for each name, one under the other. +fn draw_each(names: &[&str]) -> String { + names.iter().enumerate().map(|(i, n)| format!("q 200 0 0 80 6 {} cm /{n} Do Q ", 690 - 100 * i)).collect() +} + +/// An `/XObject` dictionary of `names`. +fn xobject_dict(names: &[(&str, ObjRef)]) -> Dict { + let mut x = Dict::new(); + for (n, r) in names { + x.set(n.as_bytes().to_vec(), Object::Ref(*r)); + } + x +} + +/// A stream drawing `/Im` (or nothing) whose `/Resources` is `resources`, with `extra` in its +/// dictionary: a form, an appearance or a tiling pattern. +fn drawing(doc: &mut Document, resources: Option, extra: &[(&str, Object)]) -> ObjRef { + let mut f = Dict::new(); + f.set(b"BBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 612.into(), 792.into()])); + if let Some(resources) = resources { + f.set(b"Resources".to_vec(), resources); + } + for (k, v) in extra { + f.set(k.as_bytes().to_vec(), v.clone()); + } + doc.add(Object::Stream(Stream::from_raw(f, draw_each(&["Im"]).into_bytes()))) +} + +fn form_entries() -> [(&'static str, Object); 2] { + [("Type", Object::name("XObject")), ("Subtype", Object::name("Form"))] +} + +/// Resources of `entries`. +fn resources_of(entries: Vec<(&str, Object)>) -> Dict { + let mut res = Dict::new(); + for (k, v) in entries { + res.set(k.as_bytes().to_vec(), v); + } + res +} + +/// Give page `p` an annotation whose normal appearance is `appearance`. +fn annotate(doc: &mut Document, p: ObjRef, appearance: ObjRef) { + let mut ap = Dict::new(); + ap.set(b"N".to_vec(), Object::Ref(appearance)); + let mut annotation = Dict::new(); + annotation.set(b"Subtype".to_vec(), Object::name("Square")); + annotation.set(b"Rect".to_vec(), Object::Array(vec![0.into(), 0.into(), 100.into(), 100.into()])); + annotation.set(b"AP".to_vec(), Object::Dict(ap)); + let annotation = doc.add(Object::Dict(annotation)); + let mut annots = doc.get(p).as_dict().and_then(|d| d.get(b"Annots").and_then(|a| a.as_array().cloned())).unwrap_or_default(); + annots.push(Object::Ref(annotation)); + doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array(annots))).unwrap(); +} + +/// `/DefaultGray` as a calibrated grey, in a `/ColorSpace` resource dictionary. +fn default_gray() -> Object { + let mut white_point = Dict::new(); + white_point.set(b"WhitePoint".to_vec(), Object::Array(vec![Object::Real(0.9505), 1.into(), Object::Real(1.089)])); + let mut c = Dict::new(); + c.set(b"DefaultGray".to_vec(), Object::Array(vec![Object::name("CalGray"), Object::Dict(white_point)])); + Object::Dict(c) +} + +/// Optimize with colour and grey settings that leave images alone, so only packing acts; the +/// report's images resampled and stored again. +fn pack_only(doc: &mut Document) -> (usize, usize) { + let keep = ImageSettings { downsample: false, target_ppi: 150.0, above_ppi: 225.0, compression: Compression::Retain }; + let r = optimize(doc, &Settings { color: keep, gray: keep, ..Settings::default() }).unwrap(); + (r.images_resampled, r.images_recompressed) +} + +/// Black-and-white RGB becomes grey for every use at once, so it stays RGB, byte for byte, when +/// any use may be beyond the page content and forms the optimizer reads, where other default +/// colour spaces may be in force: drawn by an annotation's appearance whose resources make +/// `/DefaultGray` the grey it would be drawn in; by a tiling pattern; through an `/XObject` +/// dictionary, or a form, that a page shares with such an appearance; +/// referred to with another generation by the page than by such an appearance; drawn plainly on +/// one page and, named with another generation, on a page whose resources set `/DefaultGray`; +/// drawn plainly and only listed (not drawn) where `/DefaultGray` is in force, under the same +/// generation or another, as a Type3 glyph without resources could draw it there; or held in an +/// array elsewhere (another image's `/Alternates`). +#[test] +fn black_and_white_rgb_used_beyond_the_page_content_read_stays_rgb() { + let (mut doc, image) = rgb_page_fixture(); + let [in_annotation, in_pattern, shared_xobjects, shared_form, other_generation, generation_under_defaults, listed, listed_as_1, in_array] = + [(); 9].map(|_| image(&mut doc)); + let gray_resources = |im: Object| Some(Object::Dict(resources_of(vec![("XObject", im), ("ColorSpace", default_gray())]))); + let direct = |im: ObjRef| Object::Dict(xobject_dict(&[("Im", im)])); + + let appearance = drawing(&mut doc, gray_resources(direct(in_annotation)), &form_entries()); + let pattern_entries = + [("PatternType", 1.into()), ("PaintType", 1.into()), ("TilingType", 1.into()), ("XStep", 612.into()), ("YStep", 792.into())]; + let pattern = drawing(&mut doc, Some(Object::Dict(resources_of(vec![("XObject", direct(in_pattern))]))), &pattern_entries); + let p = page(&mut doc, &[("A", in_annotation), ("B", in_pattern)], &(draw_each(&["A", "B"]) + "/Pattern cs /P scn 0 0 10 10 re f")); + annotate(&mut doc, p, appearance); + doc.update_dict(p, |d| { + let mut res = d.get(b"Resources").and_then(Object::as_dict).cloned().unwrap(); + let mut patterns = Dict::new(); + patterns.set(b"P".to_vec(), Object::Ref(pattern)); + res.set(b"Pattern".to_vec(), Object::Dict(patterns)); + d.set(b"Resources".to_vec(), Object::Dict(res)); + }) + .unwrap(); + + // An /XObject dictionary of its own, read for a page, and a form drawn by a page, each also + // used by an appearance under DefaultGray. + let x = doc.add(Object::Dict(xobject_dict(&[("Im", shared_xobjects)]))); + let q = page(&mut doc, &[], &draw_each(&["Im"])); + doc.update_dict(q, |d| d.set(b"Resources".to_vec(), Object::Dict(resources_of(vec![("XObject", Object::Ref(x))])))).unwrap(); + let appearance = drawing(&mut doc, gray_resources(Object::Ref(x)), &form_entries()); + annotate(&mut doc, q, appearance); + let form = drawing(&mut doc, Some(Object::Dict(resources_of(vec![("XObject", direct(shared_form))]))), &form_entries()); + let q = page(&mut doc, &[("F", form)], &draw_each(&["F"])); + let appearance = drawing(&mut doc, gray_resources(Object::Dict(xobject_dict(&[("Im", form)]))), &form_entries()); + annotate(&mut doc, q, appearance); + + // The page names it with generation 1 (it is 0); the appearance with 0. + let q = page(&mut doc, &[("Im", ObjRef { generation: 1, ..other_generation })], &draw_each(&["Im"])); + let appearance = drawing(&mut doc, gray_resources(direct(other_generation)), &form_entries()); + annotate(&mut doc, q, appearance); + + // Drawn plainly as 0, and as 1 where DefaultGray is in force. + page(&mut doc, &[("Im", generation_under_defaults)], &draw_each(&["Im"])); + let q = page(&mut doc, &[], &draw_each(&["Im"])); + let gray = resources_of(vec![ + ("XObject", Object::Dict(xobject_dict(&[("Im", ObjRef { generation: 1, ..generation_under_defaults })]))), + ("ColorSpace", default_gray()), + ]); + doc.update_dict(q, |d| d.set(b"Resources".to_vec(), Object::Dict(gray))).unwrap(); + + // Drawn plainly, and only listed where DefaultGray is in force, as 0 and as 1. + page(&mut doc, &[("A", listed), ("B", listed_as_1)], &draw_each(&["A", "B"])); + let q = page(&mut doc, &[], ""); + let gray = resources_of(vec![ + ("XObject", Object::Dict(xobject_dict(&[("A", listed), ("B", ObjRef { generation: 1, ..listed_as_1 })]))), + ("ColorSpace", default_gray()), + ]); + doc.update_dict(q, |d| d.set(b"Resources".to_vec(), Object::Dict(gray))).unwrap(); + + // Another image drawn on the page lists it as an alternate. + let mut alternate = Dict::new(); + alternate.set(b"Image".to_vec(), Object::Ref(in_array)); + let holder = image(&mut doc); + doc.update_dict(holder, |d| { + d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + d.set(b"Alternates".to_vec(), Object::Array(vec![Object::Dict(alternate)])); + }) + .unwrap(); + page(&mut doc, &[("Im", in_array), ("Alt", holder)], &draw_each(&["Im"])); + + let kept = [in_annotation, in_pattern, shared_xobjects, shared_form, other_generation, generation_under_defaults, listed, listed_as_1, in_array]; + let before = kept.map(|r| stream(&doc, r)); + pack_only(&mut doc); + for (i, b) in kept.iter().zip(&before) { + let s = stream(&doc, *i); + assert_eq!((&s.dict, &s.raw), (&b.dict, &b.raw), "{i:?}"); + } +} + +/// Black-and-white RGB that the page content and forms the optimizer reads are the only uses of +/// becomes grey, however its resources are written: drawn on two pages, by a form, under two +/// names on one page; through resources that are an object of their own, shared by two pages, +/// whose `/XObject` dictionary is one too; through resources an object of their own with a direct +/// `/XObject` dictionary; through resources a page inherits from the page tree; by a form whose +/// `/Resources` is a reference; by a form without resources, through its page's; listed under a +/// second name nothing draws; through resources an object of their own that a page without +/// content lists too; and with an object nothing in the document reaches referring to it as well. +#[test] +fn black_and_white_rgb_drawn_only_where_the_optimizer_reads_becomes_grey() { + let (mut doc, image) = rgb_page_fixture(); + let [everywhere, shared_resources, resources_object, inherited, form_resources_object, no_resources, spare_name, no_contents, unreachable] = + [(); 9].map(|_| image(&mut doc)); + let set_resources = |doc: &mut Document, q: ObjRef, res: Object| doc.update_dict(q, |d| d.set(b"Resources".to_vec(), res)).unwrap(); + + let form = + drawing(&mut doc, Some(Object::Dict(resources_of(vec![("XObject", Object::Dict(xobject_dict(&[("Im", everywhere)])))]))), &form_entries()); + page(&mut doc, &[("E", everywhere), ("F", form)], &draw_each(&["E", "F"])); + page(&mut doc, &[("E", everywhere), ("G", everywhere)], &draw_each(&["E", "G"])); + + let x = doc.add(Object::Dict(xobject_dict(&[("Im", shared_resources)]))); + let res = doc.add(Object::Dict(resources_of(vec![("XObject", Object::Ref(x))]))); + for _ in 0..2 { + let q = page(&mut doc, &[], &draw_each(&["Im"])); + set_resources(&mut doc, q, Object::Ref(res)); + } + let res = doc.add(Object::Dict(resources_of(vec![("XObject", Object::Dict(xobject_dict(&[("Im", resources_object)])))]))); + let q = page(&mut doc, &[], &draw_each(&["Im"])); + set_resources(&mut doc, q, Object::Ref(res)); + + let q = page(&mut doc, &[], &draw_each(&["I"])); + doc.update_dict(q, |d| { + d.remove(b"Resources"); + }) + .unwrap(); + let pages = doc.root().and_then(|r| doc.get(r).as_dict().and_then(|d| d.reference(b"Pages"))).unwrap(); + set_resources(&mut doc, pages, Object::Dict(resources_of(vec![("XObject", Object::Dict(xobject_dict(&[("I", inherited)])))]))); + + let res = doc.add(Object::Dict(resources_of(vec![("XObject", Object::Dict(xobject_dict(&[("Im", form_resources_object)])))]))); + let form = drawing(&mut doc, Some(Object::Ref(res)), &form_entries()); + page(&mut doc, &[("F", form)], &draw_each(&["F"])); + let form = drawing(&mut doc, None, &form_entries()); + page(&mut doc, &[("F", form), ("Im", no_resources)], &draw_each(&["F"])); + + page(&mut doc, &[("C", spare_name), ("D", spare_name)], &draw_each(&["C"])); + let res = doc.add(Object::Dict(resources_of(vec![("XObject", Object::Dict(xobject_dict(&[("Im", no_contents)])))]))); + for drawn in [true, false] { + let q = page(&mut doc, &[], &draw_each(&["Im"])); + set_resources(&mut doc, q, Object::Ref(res)); + if !drawn { + doc.update_dict(q, |d| { + d.remove(b"Contents"); + }) + .unwrap(); + } + } + + page(&mut doc, &[("Im", unreachable)], &draw_each(&["Im"])); + // A form nothing refers to, drawing it under DefaultGray. + let orphan = resources_of(vec![("XObject", Object::Dict(xobject_dict(&[("Im", unreachable)]))), ("ColorSpace", default_gray())]); + drawing(&mut doc, Some(Object::Dict(orphan)), &form_entries()); + + let grey = [everywhere, shared_resources, resources_object, inherited, form_resources_object, no_resources, spare_name, no_contents, unreachable]; + assert_eq!(pack_only(&mut doc), (0, grey.len())); + for i in grey { + let s = stream(&doc, i); + assert_eq!((s.dict.get(b"ColorSpace"), s.dict.int(b"BitsPerComponent")), (Some(&Object::name("DeviceGray")), Some(1)), "{i:?}"); + } +} + +/// LibreOffice writes one resources object for a page and its `/Pages` node, and a transparency +/// group's form whose `/Resources` is that same object, which the object lists in turn. Black- +/// and-white RGB in it becomes grey: every reference is one the page walk reads, the cycle +/// included. Drawn from outside as well, by an appearance under `/DefaultGray` that draws the +/// form, it stays RGB. +#[test] +fn black_and_white_rgb_in_resources_its_page_tree_and_a_form_share_becomes_grey() { + for outside in [false, true] { + let (mut doc, image) = rgb_page_fixture(); + let img = image(&mut doc); + let res = doc.add(Object::Dict(Dict::new())); + let form = drawing(&mut doc, Some(Object::Ref(res)), &form_entries()); + doc.update_dict(res, |d| d.set(b"XObject".to_vec(), Object::Dict(xobject_dict(&[("Im", img), ("Tr", form)])))).unwrap(); + let p = page(&mut doc, &[], &draw_each(&["Im", "Tr"])); + let pages = doc.root().and_then(|r| doc.get(r).as_dict().and_then(|d| d.reference(b"Pages"))).unwrap(); + for holder in [p, pages] { + doc.update_dict(holder, |d| d.set(b"Resources".to_vec(), Object::Ref(res))).unwrap(); + } + if outside { + let gray = resources_of(vec![("XObject", Object::Dict(xobject_dict(&[("Im", form)]))), ("ColorSpace", default_gray())]); + let appearance = drawing(&mut doc, Some(Object::Dict(gray)), &form_entries()); + annotate(&mut doc, p, appearance); + } + let before = stream(&doc, img); + pack_only(&mut doc); + let s = stream(&doc, img); + if outside { + assert_eq!((&s.dict, &s.raw), (&before.dict, &before.raw), "drawn from outside too"); + } else { + assert_eq!((s.dict.get(b"ColorSpace"), s.dict.int(b"BitsPerComponent")), (Some(&Object::name("DeviceGray")), Some(1))); + } + } +} + +/// Dense local mixed-sample patches are retained conservatively by the monochrome settings. +/// One-bit Flate, CCITT and masks preserve samples; eight-bit bilevel inputs may be packed +/// losslessly. Disable the independent gray/color reduction for the exact-render comparison: +/// those settings can otherwise choose smaller grayscale/JPEG output for eight-bit sources. +/// The Retain case separately checks that gray/color reduction remains available. +#[test] +fn ambiguous_monochrome_text_is_recompressed_without_lossy_reduction() { + let mut doc = Document::new_empty(); + let (w, h) = (1200u32, 480u32); + let text = bilevel(w, h, false); + let grey = [("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]; + let g4 = |doc: &mut Document, entries: &[(&str, Object)]| { + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w.into())); + d.set(b"Height".to_vec(), Object::Int(h.into())); + d.set(b"Filter".to_vec(), Object::Array(vec![Object::name("CCITTFaxDecode")])); + let mut parms = Dict::new(); + parms.set(b"K".to_vec(), Object::Int(-1)); + parms.set(b"Columns".to_vec(), Object::Int(w.into())); + d.set(b"DecodeParms".to_vec(), Object::Array(vec![Object::Dict(parms)])); + for (k, v) in entries { + d.set(k.as_bytes().to_vec(), v.clone()); + } + doc.add(Object::Stream(Stream::from_raw(d, pdfcraft_filters::encode_g4(&text, w, h).unwrap()))) + }; + // Group 4 behind ASCIIHexDecode, its parameters second in the array. + let behind_hex = |doc: &mut Document| { + let r = g4(doc, &grey); + let s = stream(doc, r); + let mut d = s.dict.clone(); + let parms = d.get(b"DecodeParms").and_then(Object::as_array).and_then(|a| a.first()).cloned().unwrap(); + d.set(b"Filter".to_vec(), Object::Array(vec![Object::name("ASCIIHexDecode"), Object::name("CCITTFaxDecode")])); + d.set(b"DecodeParms".to_vec(), Object::Array(vec![Object::Null, parms])); + let hex = s.raw.iter().map(|b| format!("{b:02x}")).chain([">".to_string()]).collect::().into_bytes(); + doc.set(r, Object::Stream(Stream::from_raw(d, hex))); + r + }; + let flate = mono_image(&mut doc, w, h, &text, None, &grey); + let fax = g4(&mut doc, &grey); + let hex_fax = behind_hex(&mut doc); + let hex_at_400 = behind_hex(&mut doc); + let decode = Object::Array(vec![1.into(), 0.into()]); + let mask = mono_image(&mut doc, w, h, &text, None, &[("ImageMask", Object::Bool(true)), ("Decode", decode.clone())]); + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w.into())); + d.set(b"Height".to_vec(), Object::Int(h.into())); + d.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + let eight = doc.add(Object::Stream(Stream::flate(d.clone(), &eight_bit(&text, w, h)))); + d.set(b"ColorSpace".to_vec(), Object::name("DeviceRGB")); + let rgb = doc.add(Object::Stream(Stream::flate(d.clone(), &samples_of(&text, w, h, [&[0, 0, 0], &[255, 255, 255]], 8)))); + d.set(b"Decode".to_vec(), Object::Array([1, 0, 1, 0, 1, 0].map(Object::Int).to_vec())); + let rgb_inverted = doc.add(Object::Stream(Stream::flate(d, &samples_of(&text, w, h, [&[255, 255, 255], &[0, 0, 0]], 8)))); + let at_400 = mono_image(&mut doc, w, h, &text, None, &grey); + let fax_at_400 = g4(&mut doc, &grey); + let mut sm = Dict::new(); + sm.set(b"Subtype".to_vec(), Object::name("Image")); + sm.set(b"Width".to_vec(), Object::Int(2)); + sm.set(b"Height".to_vec(), Object::Int(1)); + sm.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + sm.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + let smask = doc.add(Object::Stream(Stream::flate(sm, &[255, 200]))); + let soft = mono_image(&mut doc, w, h, &text, None, &[grey[0].clone(), grey[1].clone(), ("SMask", Object::Ref(smask))]); + // 144 × 57.6 pt is 2 × 0.8 in: 600 ppi. 216 × 86.4 pt is 400 ppi. + page( + &mut doc, + &[ + ("A", flate), + ("B", fax), + ("C", mask), + ("D", eight), + ("E", at_400), + ("F", fax_at_400), + ("G", soft), + ("H", hex_fax), + ("I", hex_at_400), + ("J", rgb), + ("K", rgb_inverted), + ], + "q 144 0 0 57.6 20 700 cm /A Do Q q 144 0 0 57.6 200 700 cm /B Do Q q 0 0 1 rg 144 0 0 57.6 380 700 cm /C Do Q \ + q 144 0 0 57.6 20 600 cm /D Do Q q 216 0 0 86.4 200 580 cm /E Do Q q 216 0 0 86.4 20 480 cm /F Do Q \ + q 144 0 0 57.6 260 480 cm /G Do Q q 144 0 0 57.6 430 600 cm /H Do Q q 216 0 0 86.4 20 360 cm /I Do Q q 144 0 0 57.6 260 380 cm /J Do Q \ + q 144 0 0 57.6 430 480 cm /K Do Q", + ); + let before = render(&doc); + let raw = |doc: &Document, r: ObjRef| stream(doc, r).raw; + let size = |doc: &Document, r: ObjRef| { + let d = stream(doc, r).dict; + (d.int(b"Width").unwrap(), d.int(b"Height").unwrap()) + }; + // This ragged text fixture has dense local mixed-sample patches. The conservative + // tone guard retains it too: a false positive costs size, not document content. + + // The grey and colour settings left alone too: an image of more bits would otherwise take + // them when smaller. + let mut off = doc.clone(); + let keep = ImageSettings { downsample: false, target_ppi: 150.0, above_ppi: 225.0, compression: Compression::Retain }; + let mono = MonoSettings { downsample: false, ..MonoSettings::REDUCE }; + let r = optimize(&mut off, &Settings { mono, gray: keep, color: keep, ..Settings::default() }).unwrap(); + assert_eq!(r.images_resampled, 0, "{r:?}"); + assert!([flate, fax, mask, eight, hex_fax, rgb, rgb_inverted].iter().all(|i| size(&off, *i) == (1200, 480))); + + let mut kept = doc.clone(); + let r = optimize(&mut kept, &Settings { mono: RETAIN, ..Settings::default() }).unwrap(); + // Not the inverted RGB: the colour settings leave an image with a decode array alone. + assert_eq!(r.images_resampled, 2, "{r:?}"); + assert_eq!(stream(&kept, flate).dict.name(b"Filter"), Some(&b"FlateDecode"[..]), "Retain: Flate stays Flate"); + for i in [fax, hex_fax] { + assert_eq!(stream(&kept, i).dict.get(b"Filter"), stream(&doc, i).dict.get(b"Filter"), "Retain keeps the exact filter chain"); + } + assert_eq!(raw(&kept, hex_at_400), raw(&doc, hex_at_400), "and nothing else changes"); + for i in [flate, fax, hex_fax] { + assert_eq!((size(&kept, i), decoded_image(&kept, &stream(&kept, i)).unwrap()), ((1200, 480), text.clone()), "{i:?}"); + } + let bits = [eight, rgb, rgb_inverted].map(|i| stream(&kept, i).dict.int(b"BitsPerComponent")); + assert_eq!(bits, [Some(8); 3], "Retain leaves eight bits to the grey and colour settings"); + + let untouched = [fax_at_400].map(|r| raw(&doc, r)); + let hex_len = raw(&doc, hex_at_400).len(); + let r = optimize(&mut doc, &Settings { gray: keep, color: keep, ..Settings::default() }).unwrap(); + assert_eq!((r.images_resampled, r.images_recompressed), (0, 9), "{r:?}"); + assert_eq!(stream(&doc, rgb).dict.get(b"ColorSpace"), Some(&Object::name("DeviceGray"))); + for i in [flate, fax, mask, eight, hex_fax, rgb] { + let s = stream(&doc, i); + assert_eq!((size(&doc, i), s.dict.int(b"BitsPerComponent")), ((1200, 480), if i == mask { None } else { Some(1) })); + assert!(matches!(s.filters().as_slice(), [(Filter::CcittFax | Filter::Flate, _)]), "{i:?}"); + assert_eq!(decoded_image(&doc, &s).unwrap(), text.clone(), "{i:?}"); + } + assert_eq!(stream(&doc, mask).dict.get(b"Decode"), Some(&decode), "the decode array stays"); + // Grey now, with grey's decode pair, which makes 1 black: its ties are black too. + let s = stream(&doc, rgb_inverted); + let inverting = Object::Array(vec![1.into(), 0.into()]); + assert_eq!( + (size(&doc, rgb_inverted), s.dict.get(b"ColorSpace"), s.dict.get(b"Decode")), + ((1200, 480), Some(&Object::name("DeviceGray")), Some(&inverting)) + ); + assert_eq!(decoded_image(&doc, &s).unwrap(), text.iter().map(|b| !b).collect::>(), "the same picture, ties black"); + for i in [at_400, soft, hex_at_400] { + assert_eq!((size(&doc, i), stream(&doc, i).dict.name(b"Filter")), ((1200, 480), Some(&b"CCITTFaxDecode"[..])), "{i:?}"); + } + assert!(raw(&doc, hex_at_400).len() < hex_len, "the ASCIIHexDecode is dropped"); + assert_eq!(decoded_image(&doc, &stream(&doc, hex_at_400)).unwrap(), text, "and the picture kept"); + assert_eq!([fax_at_400].map(|r| raw(&doc, r)), untouched); + let after = render(&doc); + assert_eq!(after, before, "conservative retention preserves the rendered page exactly"); +} + +/// Monochrome downsampling leaves a halftone or a dither at its resolution, as most new pixels +/// would cover both black and white and taking the sample covering more would turn its tones +/// solid: a 50% checkerboard, a 25% ordered dither and an error-diffused one, each at 600 ppi. +/// Dense ragged text is retained too. Rows wider than a fax row (`MAX_CCITT_COLUMNS`) +/// are never averaged: the averaging keeps a counter for each column. +#[test] +fn monochrome_downsampling_leaves_halftones_and_overwide_rows_at_their_resolution() { + let (w, h) = (1200u32, 480u32); + let stride = (w as usize).div_ceil(8); + let pattern = |white: &dyn Fn(usize, usize) -> bool| { + let mut data = vec![0u8; stride * h as usize]; + for y in 0..h as usize { + for x in 0..w as usize { + if white(x, y) { + data[y * stride + x / 8] |= 0x80 >> (x % 8); + } + } + } + data + }; + let diffused = dithered(w, h); + let grey = [("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]; + let mut doc = Document::new_empty(); + let text = mono_image(&mut doc, w, h, &bilevel(w, h, false), None, &grey); + let halftones = + [pattern(&|x, y| (x + y) % 2 == 0), pattern(&|x, y| x % 2 == 1 || y % 2 == 1), pattern(&|x, y| diffused[y * w as usize + x] == 255)] + .map(|data| mono_image(&mut doc, w, h, &data, None, &grey)); + // 144 × 57.6 pt is 600 ppi. + let names = ["T", "A", "B", "C"]; + let content: String = (0..4).map(|i| format!("q 144 0 0 57.6 {} 600 cm /{} Do Q ", 6 + 150 * i, names[i])).collect(); + page(&mut doc, &names.into_iter().zip([text, halftones[0], halftones[1], halftones[2]]).collect::>(), &content); + optimize(&mut doc, &Settings::default()).unwrap(); + let size = |r: ObjRef| { + let d = stream(&doc, r).dict; + (d.int(b"Width"), d.int(b"Height")) + }; + assert_eq!(size(text), (Some(1200), Some(480)), "ambiguous dense text is retained conservatively"); + for (i, r) in halftones.iter().enumerate() { + assert_eq!(size(*r), (Some(1200), Some(480)), "halftone {i}"); + } + + for (columns, resampled) in [(pdfcraft_filters::MAX_CCITT_COLUMNS, 1), (pdfcraft_filters::MAX_CCITT_COLUMNS + 1, 0)] { + let mut doc = Document::new_empty(); + let white = vec![0xFF; (columns as usize).div_ceil(8) * 2]; + let img = mono_image(&mut doc, columns, 2, &white, None, &grey); + // Far above 450 ppi both ways. + page(&mut doc, &[("A", img)], "q 612 0 0 0.1 0 400 cm /A Do Q"); + let r = optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!(r.images_resampled, resampled, "{columns} columns: {r:?}"); + } +} + +/// A fax image behind a decompression bomb is left alone: the filters before its decoder may +/// produce no more than a fax code of its size can take (about a byte a pixel), not the 1 GiB +/// limit. Here a Group 4 page at 600 ppi behind Flate, with four megabytes of zeros after its +/// code; without them it is averaged down. +#[test] +fn a_fax_image_behind_a_decompression_bomb_is_left_alone() { + let (w, h) = (1200u32, 480u32); + let g4 = pdfcraft_filters::encode_g4(&bilevel(w, h, false), w, h).unwrap(); + for padding in [0, 4 << 20] { + let mut doc = Document::new_empty(); + let mut code = g4.clone(); + code.resize(g4.len() + padding, 0); + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w.into())); + d.set(b"Height".to_vec(), Object::Int(h.into())); + d.set(b"BitsPerComponent".to_vec(), Object::Int(1)); + d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + d.set(b"Filter".to_vec(), Object::Array(vec![Object::name("FlateDecode"), Object::name("CCITTFaxDecode")])); + let mut parms = Dict::new(); + parms.set(b"K".to_vec(), Object::Int(-1)); + parms.set(b"Columns".to_vec(), Object::Int(w.into())); + d.set(b"DecodeParms".to_vec(), Object::Array(vec![Object::Null, Object::Dict(parms)])); + let img = doc.add(Object::Stream(Stream::from_raw(d, pdfcraft_filters::encode_flate(&code)))); + // 144 × 57.6 pt is 600 ppi. + page(&mut doc, &[("A", img)], "q 144 0 0 57.6 20 600 cm /A Do Q"); + let before = stream(&doc, img).raw; + let r = optimize(&mut doc, &Settings::default()).unwrap(); + if padding == 0 { + assert_eq!(r.images_resampled, 0, "ambiguous local tones are retained: {r:?}"); + assert_eq!(decoded_image(&doc, &stream(&doc, img)).unwrap(), bilevel(w, h, false)); + } else { + assert_eq!((r.images_resampled, r.images_recompressed), (0, 0), "{r:?}"); + assert_eq!(stream(&doc, img).raw, before); + } + } +} + +/// A CCITT image `w` × `h` coded as Group 4 from `bits`, behind ASCIIHexDecode when `hex`. +fn g4_image(doc: &mut Document, bits: &[u8], (w, h): (u32, u32), hex: bool) -> ObjRef { + let g4 = pdfcraft_filters::encode_g4(bits, w, h).unwrap(); + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w.into())); + d.set(b"Height".to_vec(), Object::Int(h.into())); + d.set(b"BitsPerComponent".to_vec(), Object::Int(1)); + d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + let mut parms = Dict::new(); + parms.set(b"K".to_vec(), Object::Int(-1)); + parms.set(b"Columns".to_vec(), Object::Int(w.into())); + let data = if hex { + d.set(b"Filter".to_vec(), Object::Array(vec![Object::name("ASCIIHexDecode"), Object::name("CCITTFaxDecode")])); + d.set(b"DecodeParms".to_vec(), Object::Array(vec![Object::Null, Object::Dict(parms)])); + g4.iter().flat_map(|b| format!("{b:02x}").into_bytes()).chain(*b">").collect() + } else { + d.set(b"Filter".to_vec(), Object::name("CCITTFaxDecode")); + d.set(b"DecodeParms".to_vec(), Object::Dict(parms)); + g4 + }; + doc.add(Object::Stream(Stream::from_raw(d, data))) +} + +/// What Monochrome Images leaves alone and what it may still do. A Group 4 halftone above 450 ppi +/// isn't resampled, so it stays byte for byte: alone, by default; behind ASCIIHexDecode, with +/// Retain (by default it is stored again as Group 4 alone). Under Retain an ambiguous Flate scan +/// keeps its bytes and resolution though Group 4 would be smaller. A busy Group 4 image behind ASCIIHexDecode, whose +/// code is more than three times its raster, decodes within a fax code's bound and is stored +/// again as Group 4 alone. Sixteen-bit grey of only 0x0000 and 0xFFFF isn't eight-bit black and +/// white, and keeps its bytes. +#[test] +fn monochrome_images_keep_what_they_should() { + let (w, h) = (1200u32, 480u32); + let stride = (w as usize).div_ceil(8); + let checker: Vec = (0..h as usize).flat_map(|y| vec![if y % 2 == 0 { 0xAA } else { 0x55 }; stride]).collect(); + let mut seed = 0x2545_F491_4F6C_DD1Du64; + let mut next = move || { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + seed + }; + // Twelve dark bands a row, their edges ragged by up to five samples, as a scan's are. + let mut ragged = vec![0xFFu8; stride * h as usize]; + for y in 0..h as usize { + for band in 0..12 { + let (x0, x1) = (band * 100 + (next() % 5) as usize, band * 100 + 50 + (next() % 5) as usize); + for x in x0..x1.min(w as usize) { + ragged[y * stride + x / 8] &= !(0x80 >> (x % 8)); + } + } + } + let busy: Vec = (0..256).flat_map(|y| [if y % 2 == 0 { 0xAA } else { 0xFF }; 32]).collect(); + let build = || { + let mut doc = Document::new_empty(); + let alone = g4_image(&mut doc, &checker, (w, h), false); + let wrapped = g4_image(&mut doc, &checker, (w, h), true); + let grey = [("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]; + let scan = mono_image(&mut doc, w, h, &ragged, None, &grey); + let fax = g4_image(&mut doc, &busy, (256, 256), true); + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w.into())); + d.set(b"Height".to_vec(), Object::Int(h.into())); + d.set(b"BitsPerComponent".to_vec(), Object::Int(16)); + d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + let samples: Vec = eight_bit(&bilevel(w, h, false), w, h).iter().flat_map(|v| [*v, *v]).collect(); + let deep = doc.add(Object::Stream(Stream::flate(d, &samples))); + // 144 × 57.6 pt is 600 ppi; 46.08 pt for 256 samples is 400 ppi. + page( + &mut doc, + &[("A", alone), ("B", wrapped), ("C", scan), ("D", fax), ("E", deep)], + "q 144 0 0 57.6 20 700 cm /A Do Q q 144 0 0 57.6 200 700 cm /B Do Q q 144 0 0 57.6 20 600 cm /C Do Q \ + q 46.08 0 0 46.08 200 600 cm /D Do Q q 144 0 0 57.6 20 500 cm /E Do Q", + ); + (doc, [alone, wrapped, scan, fax, deep]) + }; + let raw = |doc: &Document, r: ObjRef| stream(doc, r).raw; + let filters = |doc: &Document, r: ObjRef| stream(doc, r).filters().len(); + + let (mut doc, [alone, wrapped, scan, fax, deep]) = build(); + let before = [alone, deep].map(|r| raw(&doc, r)); + optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!([alone, deep].map(|r| raw(&doc, r)), before, "a Group 4 halftone alone, and sixteen-bit grey"); + assert_eq!(filters(&doc, wrapped), 1, "a wrapped Group 4 halftone is stored again alone by default"); + assert!(matches!(stream(&doc, scan).filters().as_slice(), [(Filter::CcittFax | Filter::Flate, _)]), "the scan: Group 4 or Flate"); + assert_eq!(decoded_image(&doc, &stream(&doc, scan)).unwrap(), ragged, "the scan preserves every sample"); + assert_eq!((stream(&doc, fax).dict.name(b"Filter"), filters(&doc, fax)), (Some(&b"CCITTFaxDecode"[..]), 1), "the busy fax image"); + + let (mut doc, [_, wrapped, scan, _, _]) = build(); + let before = raw(&doc, wrapped); + let r = optimize(&mut doc, &Settings { mono: RETAIN, ..Settings::default() }).unwrap(); + assert_eq!(raw(&doc, wrapped), before, "Retain: a wrapped Group 4 halftone stays as it is"); + let s = stream(&doc, scan); + assert_eq!((s.dict.int(b"Width"), s.dict.name(b"Filter")), (Some(1200), Some(&b"FlateDecode"[..])), "Retain: {r:?}"); + assert_eq!(decoded_image(&doc, &s).unwrap(), ragged, "Retain preserves every sample"); +} + +/// `rows` coded as CCITT Group 3, one-dimensional (`/K 0`): each row its runs in turn, white first, +/// with ITU-T T.4's terminating codes for the few lengths used here. With `on_bytes`, each row +/// starts on a byte: `Some(false)`, with no end-of-line codes (TIFF's Modified Huffman); `Some(true)`, +/// after an end-of-line code that the fill ends on a byte (T.4's fill, as TIFF writes it). +fn group_3(rows: &[&[u32]], mixed: bool, on_bytes: Option) -> Vec { + let code = |white: bool, run: u32| match (white, run) { + (true, 0) => "00110101", + (true, 1) => "000111", + (false, 1) => "010", + (true, 8) => "10011", + (true, 16) => "101010", + (true, 32) => "00011011", + (false, 8) => "000101", + (false, 16) => "0000010111", + (false, 32) => "000001101010", + _ => panic!("no code here for a run of {run}"), + }; + let mut bits = String::new(); + for row in rows { + let codes = mixed || on_bytes == Some(true); + if on_bytes.is_some() { + // The end-of-line code is 12 bits. + while !(bits.len() + if codes { 12 } else { 0 }).is_multiple_of(8) { + bits.push('0'); + } + } + if codes { + bits.push_str("000000000001"); + } + // Mixed coding (/K above 0): a 1 bit after the code, the row one-dimensional. + if mixed { + bits.push('1'); + } + row.iter().enumerate().for_each(|(i, run)| bits.push_str(code(i % 2 == 0, *run))); + } + bits.as_bytes().chunks(8).map(|c| c.iter().enumerate().fold(0u8, |b, (i, d)| b | (u8::from(*d == b'1') << (7 - i)))).collect() +} + +/// A Group 3 fax image is stored again as Group 4 when that is smaller, the same picture (to the +/// pixel on the page, against the picture in Flate); with Retain it stays Group 3. One-dimensional (/K 0) and mixed (/K 1, with +/// end-of-line codes) alike, and rows on bytes, with no codes and with codes the fill ends on a byte +/// (without `/EndOfLine`, which reading by the flag misread). +#[test] +fn group_3_fax_images_become_group_4() { + let (w, h) = (64u32, 64u32); + let runs: Vec<&[u32]> = [&[32, 32][..], &[16; 4], &[8; 8], &[0, 16, 16, 32]].into_iter().flat_map(|r| std::iter::repeat_n(r, 16)).collect(); + // White is 1, as CCITTFaxDecode gives it without /BlackIs1. + let mut bits = Vec::new(); + for row in &runs { + let mut packed = [0u8; 8]; + let mut x = 0; + for (i, run) in row.iter().enumerate() { + for p in x..x + *run as usize { + if i % 2 == 0 { + packed[p / 8] |= 0x80 >> (p % 8); + } + } + x += *run as usize; + } + bits.extend(packed); + } + for (k, on_bytes) in [(0, None), (1, None), (0, Some(false)), (0, Some(true)), (1, Some(true))] { + let g3 = group_3(&runs, k > 0, on_bytes); + let (end_of_line, encoded_byte_align) = (k > 0 && on_bytes.is_none(), on_bytes.is_some()); + let p = pdfcraft_filters::CcittParams { k, columns: w.into(), end_of_line, encoded_byte_align, ..Default::default() }; + let mut work = u64::MAX; + assert_eq!( + pdfcraft_filters::decode_ccitt_within(&g3, &p, h, &mut work).unwrap(), + bits, + "/K {k}, on bytes {on_bytes:?}: the Group 3 data is the picture" + ); + + let mut doc = Document::new_empty(); + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w.into())); + d.set(b"Height".to_vec(), Object::Int(h.into())); + d.set(b"BitsPerComponent".to_vec(), Object::Int(1)); + d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + // The page with the picture in Flate, as it should look. + let mut control = Document::new_empty(); + let flate = control.add(Object::Stream(Stream::flate(d.clone(), &bits))); + page(&mut control, &[("A", flate)], "q 64 0 0 64 100 600 cm /A Do Q"); + d.set(b"Filter".to_vec(), Object::name("CCITTFaxDecode")); + let mut parms = Dict::new(); + parms.set(b"K".to_vec(), Object::Int(k)); + parms.set(b"EndOfLine".to_vec(), Object::Bool(end_of_line)); + parms.set(b"EncodedByteAlign".to_vec(), Object::Bool(encoded_byte_align)); + parms.set(b"Columns".to_vec(), Object::Int(w.into())); + parms.set(b"Rows".to_vec(), Object::Int(h.into())); + d.set(b"DecodeParms".to_vec(), Object::Dict(parms)); + let fax = doc.add(Object::Stream(Stream::from_raw(d, g3.clone()))); + page(&mut doc, &[("A", fax)], "q 64 0 0 64 100 600 cm /A Do Q"); + + let mut kept = doc.clone(); + optimize(&mut kept, &Settings { mono: RETAIN, ..Settings::default() }).unwrap(); + assert_eq!(stream(&kept, fax).raw[..], g3[..], "/K {k}, on bytes {on_bytes:?}: Retain keeps Group 3"); + + let r = optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!((r.images_resampled, r.images_recompressed), (0, 1), "/K {k}, on bytes {on_bytes:?}: {r:?}"); + let s = stream(&doc, fax); + assert_eq!(doc.fax_params(&s).map(|p| p.k), Some(-1), "/K {k}, on bytes {on_bytes:?}: Group 4"); + assert!(s.raw.len() < g3.len(), "{} bytes, from {}", s.raw.len(), g3.len()); + assert_eq!(decoded_image(&doc, &s).unwrap(), bits, "/K {k}, on bytes {on_bytes:?}: the same picture"); + assert!(render(&doc) == render(&control), "/K {k}, on bytes {on_bytes:?}: the page looks as the picture does"); + } +} + +/// The images of a document that are images, by object number. +fn image_refs(doc: &Document) -> Vec { + doc.object_numbers() + .into_iter() + .map(|num| ObjRef { num, generation: doc.generation(num) }) + .filter(|r| matches!(&*doc.get(*r), Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image"))) + .collect() +} + +/// Black-and-white images as lossless JBIG2: the page looks the same to the pixel (so +/// JBIG2Decode gives black as 0, as the encoder assumes), and scan-like text comes out smaller +/// than as Group 4. JBIG2Decode is PDF 1.4, so a PDF 1.3 document gets `/Version /1.4` in its +/// catalog. +#[test] +fn black_and_white_images_become_jbig2() { + let mut doc = Document::new_empty(); + let (w, h) = (1200, 480); + let text = bilevel(w, h, false); + let gray = mono_image(&mut doc, w, h, &text, None, &[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]); + let decode = Object::Array(vec![1.into(), 0.into()]); + let mask = mono_image(&mut doc, w, h, &text, None, &[("ImageMask", Object::Bool(true)), ("Decode", decode.clone())]); + let indexed_cs = Object::Array(vec![ + Object::name("Indexed"), + Object::name("DeviceRGB"), + 1.into(), + Object::String(pdfcraft_cos::PdfString::literal(vec![0xC0, 0x10, 0x10, 0xF8, 0xF0, 0xE0])), + ]); + let indexed = mono_image(&mut doc, w, h, &text, None, &[("BitsPerComponent", 1.into()), ("ColorSpace", indexed_cs.clone())]); + page( + &mut doc, + &[("G", gray), ("M", mask), ("I", indexed)], + "q 300 0 0 120 20 660 cm /G Do Q q 0 0.4 0.8 rg 300 0 0 120 320 660 cm /M Do Q q 300 0 0 120 20 520 cm /I Do Q", + ); + // The same document, as PDF 1.3. + let mut bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); + bytes[..8].copy_from_slice(b"%PDF-1.3"); + let doc = Document::open(Arc::new(bytes)).unwrap(); + assert_eq!(doc.version(), "1.3"); + let before = render(&doc); + + let mut g4 = doc.clone(); + let as_g4 = optimize(&mut g4, &Settings::default()).unwrap(); + let mut jbig2 = doc.clone(); + let settings = Settings { mono: MonoSettings { compression: MonoCompression::Jbig2, ..MonoSettings::REDUCE }, ..Settings::default() }; + let r = optimize(&mut jbig2, &settings).unwrap(); + assert_eq!(r.images_recompressed, 3); + assert!(r.image_bytes_after < as_g4.image_bytes_after, "JBIG2 {} bytes, Group 4 {}", r.image_bytes_after, as_g4.image_bytes_after); + let images: Vec = image_refs(&jbig2).into_iter().map(|i| stream(&jbig2, i)).collect(); + assert_eq!(images.len(), 3); + for s in &images { + assert_eq!((s.dict.name(b"Filter"), s.dict.contains(b"DecodeParms")), (Some(&b"JBIG2Decode"[..]), false)); + } + assert!(images.iter().any(|s| s.dict.get(b"Decode") == Some(&decode)), "the decode array stays"); + let indexed_kept = + |s: &Stream| s.dict.get(b"ColorSpace").and_then(Object::as_array).and_then(|a| a.first()).and_then(Object::as_name) == Some(b"Indexed"); + assert!(images.iter().any(indexed_kept), "the colour space stays"); + let catalog = jbig2.root().and_then(|r| jbig2.get(r).as_dict().cloned()).unwrap(); + assert_eq!(catalog.name(b"Version"), Some(&b"1.4"[..])); + assert!(g4.root().and_then(|r| g4.get(r).as_dict().cloned()).is_some_and(|c| !c.contains(b"Version")), "Group 4 is PDF 1.0"); + assert!(render(&jbig2) == before, "the page looks the same"); + // The integration also supports changing an already-CCITT image to lossless JBIG2. + let mut from_g4 = g4.clone(); + let converted = optimize(&mut from_g4, &settings).unwrap(); + assert_eq!(converted.images_recompressed, 3); + assert!(render(&from_g4) == before, "CCITT decoded and re-encoded as JBIG2 keeps the picture"); +} + +/// A catalog `/Version` given by reference is read through it: a PDF 1.3 file whose catalog says +/// 1.7 by reference keeps 1.7 when its images become JBIG2, rather than being lowered to 1.4; one +/// whose catalog says 1.3 by reference gets 1.4. +#[test] +fn a_catalog_version_given_by_reference_is_read() { + for (catalog, after) in [("1.7", "1.7"), ("1.3", "1.4")] { + let mut doc = Document::new_empty(); + let (w, h) = (1200, 480); + let text = bilevel(w, h, false); + let im = mono_image(&mut doc, w, h, &text, None, &[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]); + page(&mut doc, &[("Im", im)], "q 300 0 0 120 20 660 cm /Im Do Q"); + let version = doc.add(Object::name(catalog)); + let root = doc.root().unwrap(); + doc.update_dict(root, |c| c.set(b"Version".to_vec(), Object::Ref(version))).unwrap(); + let mut bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); + bytes[..8].copy_from_slice(b"%PDF-1.3"); + let mut doc = Document::open(Arc::new(bytes)).unwrap(); + let settings = Settings { mono: MonoSettings { compression: MonoCompression::Jbig2, ..MonoSettings::REDUCE }, ..Settings::default() }; + let r = optimize(&mut doc, &settings).unwrap(); + assert_eq!(r.images_recompressed, 1); + let root = doc.root().unwrap(); + let v = doc.get(root).as_dict().and_then(|c| c.get(b"Version").cloned()).unwrap(); + assert_eq!(doc.resolve(&v).as_name(), Some(after.as_bytes()), "catalog {catalog} by reference"); + } +} + +/// Text as a renderer draws it at 300 dpi: lines 48 rows apart of a few letter shapes, each +/// `#` a 3 × 3 block, three columns apart with a wider gap between words, every copy of a letter +/// the same to the pixel. +fn rendered_text(w: u32, h: u32) -> Vec { + const LETTERS: [&[&str]; 5] = [ + &["##", "##", "##", "##", "##", "##", "##", "##", "##", "##"], + &["......", ".####.", "##..##", "##..##", "##..##", "##..##", ".####."], + &["##....", "##....", "##....", "#####.", "##..##", "##..##", "##..##", "#####."], + &["##..##", "##..##", "##..##", "##..##", ".#####"], + &["##", "##", "..", "##", "##", "##", "##", "##"], + ]; + let stride = (w as usize).div_ceil(8); + let mut data = vec![0xFF; stride * h as usize]; + let mut seed = 0x2545_F491_4F6C_DD1Du64; + let mut next = move || { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + seed + }; + for baseline in (32..h as usize).step_by(48) { + let mut x = 8; + while x + 20 < w as usize { + let letter = LETTERS[(next() % 5) as usize]; + for (dy, row) in letter.iter().enumerate() { + for (dx, c) in row.bytes().enumerate() { + for (py, px) in (0..9).map(|k| (baseline + 1 + 3 * dy + k / 3 - 3 * letter.len(), x + 3 * dx + k % 3)) { + if c == b'#' { + data[py * stride + px / 8] &= !(0x80 >> (px % 8)); + } + } + } + } + x += 3 * letter[0].len() + 3 + if next() % 5 == 0 { 12 } else { 0 }; + } + } + data +} + +/// Text whose letters repeat exactly, as rendered text does, becomes JBIG2 symbols: each letter +/// stored once and its copies placed, several times smaller than JBIG2's generic coding of the +/// same image, and the page looks the same. +#[test] +fn rendered_text_becomes_jbig2_symbols() { + let mut doc = Document::new_empty(); + let (w, h) = (600, 480); + let text = rendered_text(w, h); + let im = mono_image(&mut doc, w, h, &text, None, &[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]); + // One image pixel a device pixel at scale 1, so a wrong pixel shows. + page(&mut doc, &[("Im", im)], "q 600 0 0 480 6 300 cm /Im Do Q"); + let before = render(&doc); + let settings = Settings { mono: MonoSettings { compression: MonoCompression::Jbig2, ..MonoSettings::REDUCE }, ..Settings::default() }; + let r = optimize(&mut doc, &settings).unwrap(); + assert_eq!(r.images_recompressed, 1); + let s = stream(&doc, im); + assert_eq!(s.dict.name(b"Filter"), Some(&b"JBIG2Decode"[..])); + let generic = pdfcraft_filters::encode_jbig2_generic(&text, w, h).unwrap(); + assert!(s.raw.len() * 2 < generic.len(), "symbols {} bytes, generic {}", s.raw.len(), generic.len()); + assert!(render(&doc) == before, "the page looks the same"); +} + +/// A JBIG2 scan goes through Monochrome Images like any black-and-white image. Above 450 ppi it +/// is averaged as its Flate copy is, to the same samples, and stored as each compression asks: +/// Group 4 or JBIG2, or Flate when that is smaller, or under Retain JBIG2 again; alone or behind another +/// filter, and with `/JBIG2Globals`, which the new image doesn't refer to. Below 450 ppi it is +/// stored again only when that is smaller: a generic region of text stays under Group 4, which +/// is larger, and under Retain, and becomes symbols with JBIG2 asked for; behind ASCIIHex it is +/// stored again alone, as Group 4 where the hex is spaced out. A JBIG2 page taller than the image +/// is left alone. +#[test] +fn jbig2_scans_are_averaged_and_stored_again() { + // Black squares 16 samples a side on even rows and columns: at 600 ppi, halving leaves no new + // pixel covering both samples, so nothing keeps the picture at its resolution. + let (w, h) = (1200u32, 480u32); + let stride = (w as usize).div_ceil(8); + let mut squares = vec![0xFFu8; stride * h as usize]; + for (top, left) in (0..h as usize / 32).flat_map(|r| (0..w as usize / 32).map(move |c| (r * 32 + 8, c * 32 + 8))) { + for y in top..top + 16 { + for x in left..left + 16 { + squares[y * stride + x / 8] &= !(0x80 >> (x % 8)); + } + } + } + let grey = [("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]; + let hex = |coded: &[u8]| -> Vec { coded.iter().flat_map(|b| format!("{b:02x}").into_bytes()).chain(*b">").collect() }; + // An image of `coded` behind `filters`, with globals a decoder skips when `globals`, drawn at + // `width` × `height` pt. + let jbig2_page = |coded: &[u8], filters: &[&str], globals: bool, (iw, ih): (u32, u32), (width, height): (f64, f64)| { + let mut doc = Document::new_empty(); + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(iw.into())); + d.set(b"Height".to_vec(), Object::Int(ih.into())); + for (k, v) in &grey { + d.set(k.as_bytes().to_vec(), v.clone()); + } + d.set(b"Filter".to_vec(), Object::Array(filters.iter().map(|f| Object::name(f)).collect())); + if globals { + let g = doc.add(Object::Stream(Stream::from_raw(Dict::new(), vec![0, 0, 0, 0, 0x3E, 0, 0, 0, 0, 0, 4, 0, 0, 0, 1]))); + let mut parms = Dict::new(); + parms.set(b"JBIG2Globals".to_vec(), Object::Ref(g)); + let entries = filters.iter().map(|f| if *f == "JBIG2Decode" { Object::Dict(parms.clone()) } else { Object::Null }).collect(); + d.set(b"DecodeParms".to_vec(), Object::Array(entries)); + } + let data = if filters.len() > 1 { hex(coded) } else { coded.to_vec() }; + let im = doc.add(Object::Stream(Stream::from_raw(d, data))); + page(&mut doc, &[("Im", im)], &format!("q {width} 0 0 {height} 20 600 cm /Im Do Q")); + (doc, im) + }; + let coded = pdfcraft_filters::encode_jbig2_generic(&squares, w, h).unwrap(); + // 144 × 57.6 pt is 600 ppi. + let at_600 = (144.0, 57.6); + for compression in [MonoCompression::CcittG4, MonoCompression::Jbig2, MonoCompression::Retain] { + let settings = Settings { mono: MonoSettings { compression, ..MonoSettings::REDUCE }, ..Settings::default() }; + let mut doc = Document::new_empty(); + let im = mono_image(&mut doc, w, h, &squares, None, &grey); + page(&mut doc, &[("Im", im)], "q 144 0 0 57.6 20 600 cm /Im Do Q"); + assert_eq!(optimize(&mut doc, &settings).unwrap().images_resampled, 1, "Flate, {compression:?}"); + let averaged = decoded_image(&doc, &stream(&doc, im)).unwrap(); + for (filters, globals) in [(&["JBIG2Decode"][..], false), (&["ASCIIHexDecode", "JBIG2Decode"][..], false), (&["JBIG2Decode"][..], true)] { + let (mut doc, im) = jbig2_page(&coded, filters, globals, (w, h), at_600); + let r = optimize(&mut doc, &settings).unwrap(); + let s = stream(&doc, im); + let what = format!("{filters:?}, globals {globals}, {compression:?}: {r:?}"); + assert_eq!((r.images_resampled, s.dict.int(b"Width"), s.dict.int(b"Height")), (1, Some(600), Some(240)), "{what}"); + assert_eq!(decoded_image(&doc, &s).unwrap(), averaged, "{what}"); + assert!(!s.dict.contains(b"DecodeParms") || s.dict.name(b"Filter") == Some(&b"CCITTFaxDecode"[..]), "{what}"); + let filter = s.dict.name(b"Filter").map(<[u8]>::to_vec); + // The smallest of what each compression may store; under Retain, JBIG2 again. + let allowed: &[&[u8]] = match compression { + MonoCompression::CcittG4 => &[b"CCITTFaxDecode", b"FlateDecode"], + MonoCompression::Jbig2 => &[b"JBIG2Decode", b"FlateDecode"], + MonoCompression::Retain => &[b"JBIG2Decode"], + }; + assert!(filter.as_deref().is_some_and(|f| allowed.contains(&f)), "{what}: {:?}", filter.map(String::from_utf8)); + } + } + + // At 300 ppi, below 450: rendered text, whose letters repeat, coded as one generic region. + let (tw, th) = (600u32, 480u32); + let text = rendered_text(tw, th); + let generic = pdfcraft_filters::encode_jbig2_generic(&text, tw, th).unwrap(); + let at_300 = (144.0, 115.2); + for compression in [MonoCompression::CcittG4, MonoCompression::Retain] { + let settings = Settings { mono: MonoSettings { compression, ..MonoSettings::REDUCE }, ..Settings::default() }; + let (mut doc, im) = jbig2_page(&generic, &["JBIG2Decode"], false, (tw, th), at_300); + let before = stream(&doc, im); + optimize(&mut doc, &settings).unwrap(); + assert_eq!(stream(&doc, im), before, "JBIG2 below 450 ppi, {compression:?}"); + } + let jbig2_asked = Settings { mono: MonoSettings { compression: MonoCompression::Jbig2, ..MonoSettings::REDUCE }, ..Settings::default() }; + let (mut doc, im) = jbig2_page(&generic, &["JBIG2Decode"], false, (tw, th), at_300); + let r = optimize(&mut doc, &jbig2_asked).unwrap(); + let s = stream(&doc, im); + assert_eq!((r.images_resampled, r.images_recompressed, s.dict.name(b"Filter")), (0, 1, Some(&b"JBIG2Decode"[..])), "{r:?}"); + assert!(s.raw.len() * 2 < generic.len(), "symbols {} bytes, the generic region {}", s.raw.len(), generic.len()); + assert_eq!(decoded_image(&doc, &s).unwrap(), text); + // Behind ASCIIHex it is stored again alone, being smaller so. + let (mut doc, im) = jbig2_page(&generic, &["ASCIIHexDecode", "JBIG2Decode"], false, (tw, th), at_300); + optimize(&mut doc, &jbig2_asked).unwrap(); + let s = stream(&doc, im); + assert_eq!((s.filters().len(), s.dict.name(b"Filter")), (1, Some(&b"JBIG2Decode"[..]))); + assert_eq!(decoded_image(&doc, &s).unwrap(), text); + // So it is as Group 4 when that is smaller: here the hex digits are spaced out, as ASCIIHex + // allows, so that the stream is many times the code. + let (mut doc, im) = jbig2_page(&generic, &["ASCIIHexDecode", "JBIG2Decode"], false, (tw, th), at_300); + let mut padded = stream(&doc, im); + padded.raw = generic.iter().flat_map(|b| format!("{b:02x}{}", " ".repeat(30)).into_bytes()).chain(*b">").collect::>().into(); + doc.set(im, Object::Stream(padded)); + optimize(&mut doc, &Settings::default()).unwrap(); + let s = stream(&doc, im); + assert_eq!((s.filters().len(), s.dict.name(b"Filter")), (1, Some(&b"CCITTFaxDecode"[..])), "Group 4 alone"); + assert_eq!(decoded_image(&doc, &s).unwrap(), text); + + // A JBIG2 page taller than the image: the renderer draws the whole page into it, so it isn't + // read, and stays as it is. + let (mut doc, im) = jbig2_page(&coded, &["JBIG2Decode"], false, (w, h - 8), at_600); + let before = stream(&doc, im); + optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!(stream(&doc, im), before, "a taller page"); +} + +/// A whole 300 dpi letter page of rendered text, more marks than one JBIG2 text region of the +/// decoder PdfCraft renders with takes, still looks the same as JBIG2 symbols. +#[test] +fn a_dense_page_of_rendered_text_looks_the_same_as_jbig2_symbols() { + let mut doc = Document::new_empty(); + let (w, h) = (2550, 3300); + let text = rendered_text(w, h); + let im = mono_image(&mut doc, w, h, &text, None, &[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]); + page(&mut doc, &[("Im", im)], "q 612 0 0 792 0 0 cm /Im Do Q"); + let before = render(&doc); + let settings = Settings { mono: MonoSettings { compression: MonoCompression::Jbig2, ..MonoSettings::REDUCE }, ..Settings::default() }; + let r = optimize(&mut doc, &settings).unwrap(); + assert_eq!(r.images_recompressed, 1); + let s = stream(&doc, im); + assert_eq!(s.dict.name(b"Filter"), Some(&b"JBIG2Decode"[..])); + assert!(s.raw.len() * 2 < pdfcraft_filters::encode_jbig2_generic(&text, w, h).unwrap().len(), "symbol coded"); + // The copies take several text regions (segment type 7, ISO/IEC 14492 7.3), read from the + // segment headers (7.2): number, flags, referred-to count and numbers, page, data length. + let mut regions = 0; + let mut at = 0; + while let Some(header) = s.raw.get(at..at + 6) { + let refers = usize::from(header[5] >> 5); + let length = u32::from_be_bytes(s.raw[at + 7 + refers..at + 11 + refers].try_into().unwrap()) as usize; + regions += usize::from(header[4] & 0x3F == 7); + at += 11 + refers + length; + } + assert!(regions > 1, "{regions} text regions"); + assert!(render(&doc) == before, "the page looks the same"); +} + #[test] fn settings_choose_what_happens() { let mut doc = Document::new_empty(); @@ -1421,6 +3410,42 @@ fn lzw_streams_become_flate_and_decode_to_the_same_bytes() { assert!(!stream(&doc, c_late).dict.contains(b"DecodeParms"), "nothing left to say for the content stream"); } +/// A stream can take a second or more to store again, so a cancel lands between any two of them, +/// and nothing runs after it. +#[test] +fn a_cancel_lands_during_the_lzw_pass() { + use pdfcraft_optimize::{OptimizeError, Stage, optimize_with_progress}; + let build = |streams: usize| { + let mut doc = Document::new_empty(); + let mut d = Dict::new(); + d.set(b"Filter".to_vec(), Object::name("LZWDecode")); + for _ in 0..streams { + doc.add(Object::Stream(Stream::from_raw(d.clone(), lzw(&lzw_ops(), &Params::default())))); + } + page(&mut doc, &[], "0 0 1 1 re f"); + doc + }; + let asked = |streams: usize| { + let mut asked = 0; + optimize_with_progress(&mut build(streams), &Settings::default(), &mut |s| { + asked += usize::from(s == Stage::CleanUp); + true + }) + .unwrap(); + asked + }; + let none = asked(0); + assert_eq!(asked(3), none + 3, "once a stream"); + let mut left = none + 1; + let mut doc = build(3); + let r = optimize_with_progress(&mut doc, &Settings::default(), &mut |s| { + left -= usize::from(s == Stage::CleanUp); + left > 0 + }); + assert!(matches!(r, Err(OptimizeError::Cancelled)), "{r:?}"); + assert_eq!(left, 0, "nothing runs after it"); +} + #[test] fn a_page_drawn_from_lzw_streams_renders_the_same_after_conversion() { let png = Params { predictor: 12, colors: 3, columns: 64, early_change: 0, ..Params::default() }; @@ -1536,3 +3561,329 @@ fn lzw_streams_that_cannot_be_read_or_are_not_asked_for_stay_unchanged() { assert_eq!(optimize(&mut doc, &Settings::default()).unwrap().lzw_streams, 1); assert_eq!(stream(&doc, c).decoded_strict().unwrap(), ops); } + +/// An indexed image's palette says which sample is dark, so a thin rule survives averaging as it does +/// in grey: with the palette either way round, and the decode array either way, the averaged image +/// holds the averaged DeviceGray one's samples (flipped where its samples are), and looks the same +/// through a full save and render. +#[test] +fn indexed_thin_rules_survive_averaging_for_both_palette_orders_and_decodes() { + let (w, h) = (1200u32, 480u32); + let stride = (w as usize).div_ceil(8); + let mut gray_data = vec![0xFF; stride * h as usize]; + for y in 0..h as usize { + gray_data[y * stride + 101 / 8] &= !(0x80 >> (101 % 8)); + } + let mut gray = Document::new_empty(); + let gray_image = mono_image(&mut gray, w, h, &gray_data, None, &[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]); + page(&mut gray, &[("Im", gray_image)], "q 144 0 0 57.6 20 600 cm /Im Do Q"); + let gray_render = render(&gray); + optimize(&mut gray, &Settings::default()).unwrap(); + let gray_after = render(&Document::open(Arc::new(write_full(&gray, &SaveOptions::default()).unwrap())).unwrap()); + let gray_stream = stream(&gray, gray_image); + let gray_samples = decoded_image(&gray, &gray_stream).unwrap(); + assert_eq!((gray_stream.dict.int(b"Width"), gray_stream.dict.int(b"Height")), (Some(600), Some(240))); + assert!(gray_samples.iter().any(|b| *b != 0xFF), "the DeviceGray thin rule survives"); + for white_first in [false, true] { + for inverted in [false, true] { + let mut data = vec![0xFF; stride * h as usize]; + for y in 0..h as usize { + data[y * stride + 101 / 8] &= !(0x80 >> (101 % 8)); + } + if white_first != inverted { + data.iter_mut().for_each(|b| *b = !*b); + } + let palette = if white_first { vec![255, 0] } else { vec![0, 255] }; + let cs = Object::Array(vec![ + Object::name("Indexed"), + Object::name("DeviceGray"), + 1.into(), + Object::String(pdfcraft_cos::PdfString::literal(palette)), + ]); + let mut entries = vec![("BitsPerComponent", 1.into()), ("ColorSpace", cs)]; + if inverted { + entries.push(("Decode", Object::Array(vec![1.into(), 0.into()]))); + } + let mut doc = Document::new_empty(); + let image = mono_image(&mut doc, w, h, &data, None, &entries); + page(&mut doc, &[("Im", image)], "q 144 0 0 57.6 20 600 cm /Im Do Q"); + let before = render(&doc); + assert_eq!(before, gray_render, "the palette image is visually identical to DeviceGray"); + optimize(&mut doc, &Settings::default()).unwrap(); + let s = stream(&doc, image); + assert_eq!((s.dict.int(b"Width"), s.dict.int(b"Height")), (Some(600), Some(240)), "white-first={white_first}, inverted={inverted}"); + // The rows are 600 samples, whole bytes: flipping each byte flips each sample. + let expected: Vec = gray_samples.iter().map(|b| if white_first != inverted { !b } else { *b }).collect(); + assert_eq!(decoded_image(&doc, &s).unwrap(), expected, "white-first={white_first}, inverted={inverted}: the rule is dark"); + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert!(render(&saved) == gray_after, "palette white-first={white_first}, inverted={inverted}"); + } + } +} + +/// An indexed image also drawn where the optimizer doesn't read (an annotation's appearance, where +/// other default colour spaces may be in force and change which palette entry is darker) keeps its +/// resolution, as black-and-white RGB keeps its colour space; drawn only on the page, it is +/// averaged. A grey image is averaged either way: sample 0 stays dark in a grey space, and only an +/// indexed image's palette depends on what's in force where it is drawn. +#[test] +fn an_indexed_image_used_beyond_the_page_content_read_is_not_averaged() { + let (w, h) = (1200u32, 480u32); + let stride = (w as usize).div_ceil(8); + let mut data = vec![0xFF; stride * h as usize]; + for y in 0..h as usize { + data[y * stride + 101 / 8] &= !(0x80 >> (101 % 8)); + } + let palette = Object::String(pdfcraft_cos::PdfString::literal(vec![0, 0, 0, 255, 255, 255])); + let indexed = Object::Array(vec![Object::name("Indexed"), Object::name("DeviceRGB"), 1.into(), palette]); + let cases = [(&indexed, false, 600), (&indexed, true, 1200), (&Object::name("DeviceGray"), false, 600), (&Object::name("DeviceGray"), true, 600)]; + for (cs, in_annotation, width) in cases { + let mut doc = Document::new_empty(); + let image = mono_image(&mut doc, w, h, &data, None, &[("BitsPerComponent", 1.into()), ("ColorSpace", cs.clone())]); + // 144 × 57.6 pt is 600 ppi. + let p = page(&mut doc, &[("Im", image)], "q 144 0 0 57.6 20 600 cm /Im Do Q"); + if in_annotation { + let resources = Object::Dict({ + let mut r = Dict::new(); + r.set(b"XObject".to_vec(), Object::Dict(xobject_dict(&[("Im", image)]))); + r + }); + let appearance = drawing(&mut doc, Some(resources), &form_entries()); + annotate(&mut doc, p, appearance); + } + optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!(stream(&doc, image).dict.int(b"Width"), Some(width), "{cs:?}, in an annotation: {in_annotation}"); + } +} + +/// Which palette entry is darker comes from the colours, weighted as luma, and blue is darker than +/// red whichever comes first. An array shaped like an indexed colour space and named otherwise has +/// no palette, so its samples aren't averaged. +#[test] +fn an_indexed_palette_is_read_by_the_luma_of_its_colours() { + let (w, h) = (1200u32, 480u32); + let stride = (w as usize).div_ceil(8); + let mut data = vec![0xFF; stride * h as usize]; + for y in 0..h as usize { + data[y * stride + 101 / 8] &= !(0x80 >> (101 % 8)); + } + let palette = Object::String(pdfcraft_cos::PdfString::literal(vec![0, 0, 255, 255, 0, 0])); + let optimized = |space: &str| { + let cs = Object::Array(vec![Object::name(space), Object::name("DeviceRGB"), 1.into(), palette.clone()]); + let mut doc = Document::new_empty(); + let image = mono_image(&mut doc, w, h, &data, None, &[("BitsPerComponent", 1.into()), ("ColorSpace", cs)]); + page(&mut doc, &[("Im", image)], "q 144 0 0 57.6 20 600 cm /Im Do Q"); + optimize(&mut doc, &Settings::default()).unwrap(); + let s = stream(&doc, image); + (s.dict.int(b"Width"), decoded_image(&doc, &s).unwrap()) + }; + let (width, samples) = optimized("Indexed"); + assert_eq!(width, Some(600)); + assert!(samples.iter().any(|b| *b != 0xFF), "the blue line, half a new pixel wide, stays as the dark sample"); + assert_eq!(optimized("NotIndexed").0, Some(1200)); +} + +/// What is averaged, by how the image is spelled. A family or a base by reference, or a device base +/// alone in an array, is the same colour space. `/I`, an inline abbreviation, and a real `hival` +/// aren't one, and neither is a table too large to be a palette, which images share and which +/// would be read for each. A colour key can hide the dark sample a tie goes to, so a thin rule of +/// the other would be lost, in grey too; a stencil `/Mask` doesn't. +#[test] +fn an_indexed_image_is_averaged_only_in_a_spelling_it_can_read() { + let (w, h) = (1200u32, 480u32); + let stride = (w as usize).div_ceil(8); + let mut data = vec![0xFF; stride * h as usize]; + for y in 0..h as usize { + data[y * stride + 101 / 8] &= !(0x80 >> (101 % 8)); + } + let table = || Object::String(pdfcraft_cos::PdfString::literal(vec![0, 0, 0, 255, 255, 255])); + let indexed = |family: Object, base: Object, hival: Object, table: Object| Object::Array(vec![family, base, hival, table]); + let rgb = || Object::name("DeviceRGB"); + let plain = || indexed(Object::name("Indexed"), rgb(), 1.into(), table()); + let width = |make: &dyn Fn(&mut Document) -> Vec<(&'static str, Object)>| { + let mut doc = Document::new_empty(); + let mut entries = vec![("BitsPerComponent", Object::Int(1))]; + entries.extend(make(&mut doc)); + let image = mono_image(&mut doc, w, h, &data, None, &entries); + page(&mut doc, &[("Im", image)], "q 144 0 0 57.6 20 600 cm /Im Do Q"); + optimize(&mut doc, &Settings::default()).unwrap(); + stream(&doc, image).dict.int(b"Width") + }; + let averaged = Some(600); + let kept = Some(1200); + assert_eq!(width(&|_| vec![("ColorSpace", plain())]), averaged, "direct"); + assert_eq!( + width(&|doc: &mut Document| { + let family = doc.add(Object::name("Indexed")); + vec![("ColorSpace", indexed(Object::Ref(family), rgb(), 1.into(), table()))] + }), + averaged, + "the family by reference" + ); + let base = Object::Array(vec![rgb()]); + assert_eq!(width(&|_| vec![("ColorSpace", indexed(Object::name("Indexed"), base.clone(), 1.into(), table()))]), averaged, "the base in an array"); + assert_eq!(width(&|_| vec![("ColorSpace", indexed(Object::name("I"), rgb(), 1.into(), table()))]), kept, "an inline abbreviation"); + assert_eq!(width(&|_| vec![("ColorSpace", indexed(Object::name("Indexed"), rgb(), Object::Real(1.0), table()))]), kept, "a real hival"); + let long = Object::String(pdfcraft_cos::PdfString::literal([vec![0, 0, 0, 255, 255, 255], vec![0; 4097]].concat())); + assert_eq!(width(&|_| vec![("ColorSpace", indexed(Object::name("Indexed"), rgb(), 1.into(), long.clone()))]), kept, "a string past a palette"); + // 16 KiB of raw data is all a table may take, whatever it decodes to. + let mut d = Dict::new(); + d.set(b"Filter".to_vec(), Object::name("ASCIIHexDecode")); + let table_of = |raw: usize| { + let d = d.clone(); + let hex: Vec = b"000000FFFFFF".iter().copied().chain(std::iter::repeat_n(b' ', raw - 13)).chain(*b">").collect(); + move |doc: &mut Document| doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(d.clone(), hex.clone()))) + }; + for (raw, expected) in [(16384, averaged), (16385, kept)] { + let add = table_of(raw); + assert_eq!( + width(&|doc: &mut Document| { + let table = add(doc); + vec![("ColorSpace", indexed(Object::name("Indexed"), rgb(), 1.into(), Object::Ref(table)))] + }), + expected, + "a stream of {raw} bytes" + ); + } + let key = || Object::Array(vec![0.into(), 0.into()]); + assert_eq!(width(&|_| vec![("ColorSpace", plain()), ("Mask", key())]), kept, "a colour key, indexed"); + assert_eq!(width(&|_| vec![("ColorSpace", Object::name("DeviceGray")), ("Mask", key())]), kept, "a colour key, grey"); + assert_eq!( + width(&|doc: &mut Document| { + let key = doc.add(key()); + vec![("ColorSpace", plain()), ("Mask", Object::Ref(key))] + }), + kept, + "a colour key by reference" + ); +} + +/// White margins must not hide a small screened photograph from the tone guard. +#[test] +fn mixed_text_and_small_halftone_regions_keep_their_tones_with_wide_white_margins() { + let (w, h) = (1200u32, 480u32); + let stride = (w as usize).div_ceil(8); + for side in [8usize, 40, 300] { + let mut data = vec![0xFF; stride * h as usize]; + for y in 40..40 + side { + for x in 100..100 + side { + if (x + y) % 2 == 0 { + data[y * stride + x / 8] &= !(0x80 >> (x % 8)); + } + } + } + // A separate one-pixel text/rule stroke makes this a mixed-content scan. + for y in 380..420 { + data[y * stride + 801 / 8] &= !(0x80 >> (801 % 8)); + } + let mut doc = Document::new_empty(); + let image = mono_image(&mut doc, w, h, &data, None, &[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]); + page(&mut doc, &[("Im", image)], "q 144 0 0 57.6 20 600 cm /Im Do Q"); + let before = render(&doc); + optimize(&mut doc, &Settings::default()).unwrap(); + let s = stream(&doc, image); + assert_eq!((s.dict.int(b"Width"), s.dict.int(b"Height")), (Some(w.into()), Some(h.into()))); + assert_eq!(decoded_image(&doc, &s).unwrap(), data, "screened samples survive, not a solid patch"); + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert_eq!(render(&saved), before, "local halftone {side}x{side}"); + } +} + +/// A one-bit stencil of `w` × `h`, solid but for a hole in its middle. +fn stencil(doc: &mut Document, w: u32, h: u32) -> ObjRef { + let row = (w as usize).div_ceil(8); + let mut bits = vec![0u8; row * h as usize]; + for y in (h as usize / 3)..(2 * h as usize / 3) { + for x in (w as usize / 3)..(2 * w as usize / 3) { + bits[y * row + x / 8] |= 0x80 >> (x % 8); + } + } + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"ImageMask".to_vec(), Object::Bool(true)); + d.set(b"Width".to_vec(), Object::Int(i64::from(w))); + d.set(b"Height".to_vec(), Object::Int(i64::from(h))); + d.set(b"BitsPerComponent".to_vec(), Object::Int(1)); + doc.add(Object::Stream(Stream::flate(d, &bits))) +} + +/// A picture with a stencil mask is made smaller like any other: the mask has a resolution of its +/// own, so it stays exactly as it was and the hole it cuts stays where it was. +#[test] +fn a_picture_with_a_stencil_mask_is_downsampled_under_its_mask() { + let mut doc = Document::new_empty(); + let mask = stencil(&mut doc, 1200, 1200); + let img = image(&mut doc, 1200, 1200, 3, true, None); + let mut s = stream(&doc, img); + s.dict.set(b"Mask".to_vec(), Object::Ref(mask)); + doc.set(img, Object::Stream(s)); + let mask_before = stream(&doc, mask); + // 1200 pixels over two inches: 600 ppi. + page(&mut doc, &[("Im0", img)], "0 0 1 rg 0 0 612 792 re f q 144 0 0 144 100 100 cm /Im0 Do Q"); + let before = render(&doc); + let report = optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!(report.images_resampled, 1, "{report:?}"); + let after = stream(&doc, img); + assert_eq!(after.dict.int(b"Width"), Some(300), "150 ppi"); + assert_eq!(after.dict.reference(b"Mask"), Some(mask), "the mask stays on it"); + assert_eq!(stream(&doc, mask), mask_before, "and doesn't change"); + let rendered = render(&doc); + assert_eq!(rendered.len(), before.len()); + let changed = before.iter().zip(&rendered).filter(|(a, b)| a.abs_diff(**b) > 96).count(); + assert!(changed * 200 < before.len(), "the masked picture looks the same: {changed} of {} samples far off", before.len()); + // The hole, the middle third of the picture on the 612 by 792 page, still shows what's behind it. + // Its first pixel all round is left out: the coarser picture blends into that a little. + for y in 597..643 { + for x in 149..195 { + let i = (y * 612 + x) * 4; + assert_eq!(rendered[i..i + 4], before[i..i + 4], "hole pixel ({x}, {y})"); + } + } +} + +/// A colour-key mask names sample values: resampling or JPEG would make holes where there were none +/// and fill those there were, so the picture stays as it is, however the array is written. A mask +/// that can't be read as a stencil is no reason to change the picture either. +#[test] +fn a_picture_whose_mask_is_not_a_stencil_is_left_alone() { + type Mask = fn(&mut Document) -> Object; + fn key() -> Object { + Object::Array([250, 255, 250, 255, 250, 255].map(Object::Int).to_vec()) + } + fn not_a_stencil(doc: &mut Document, change: fn(&mut Dict)) -> Object { + let mask = stencil(doc, 1200, 1200); + let mut s = stream(doc, mask); + change(&mut s.dict); + doc.set(mask, Object::Stream(s)); + Object::Ref(mask) + } + let masks: [(&str, Mask); 5] = [ + ("direct array", |_| key()), + ("array by reference", |doc| Object::Ref(doc.add(key()))), + ("nothing there", |doc| Object::Ref(doc.add(Object::Null))), + ("an image that says it isn't a stencil", |doc| { + not_a_stencil(doc, |d| { + d.set(b"ImageMask".to_vec(), Object::Bool(false)); + }) + }), + ("an image that doesn't say it is one", |doc| { + not_a_stencil(doc, |d| { + d.remove(b"ImageMask"); + }) + }), + ]; + for (what, mask) in masks { + let mut doc = Document::new_empty(); + let img = image(&mut doc, 1200, 1200, 3, true, None); + let mut s = stream(&doc, img); + let mask = mask(&mut doc); + s.dict.set(b"Mask".to_vec(), mask); + doc.set(img, Object::Stream(s)); + let before = stream(&doc, img); + page(&mut doc, &[("Im0", img)], "q 144 0 0 144 100 100 cm /Im0 Do Q"); + let report = optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!(report.images_resampled + report.images_recompressed, 0, "{what}: {report:?}"); + assert_eq!(stream(&doc, img), before, "{what}"); + } +} diff --git a/crates/organize/src/comments.rs b/crates/organize/src/comments.rs new file mode 100644 index 0000000000..dd5d63ebc4 --- /dev/null +++ b/crates/organize/src/comments.rs @@ -0,0 +1,657 @@ +//! Import comments from another PDF (Acrobat: the Comments list's Import Data File, with a PDF +//! chosen): each comment is copied onto the page with the same number, at the same place, with +//! its appearance, pop-up and replies. + +use std::collections::{HashMap, HashSet}; + +use pdfcraft_cos::{Dict, Document, ObjRef, Object}; + +use crate::import::{Copier, register_layers}; +use crate::{OrganizeError, walk}; + +/// The annotation subtypes copied as comments: the markup annotations of ISO 32000-2 Table 171, +/// except redaction marks (`Redact`) and `Projection`. +const COMMENTS: [&[u8]; 16] = [ + b"Text", + b"FreeText", + b"Line", + b"Square", + b"Circle", + b"Polygon", + b"PolyLine", + b"Highlight", + b"Underline", + b"Squiggly", + b"StrikeOut", + b"Stamp", + b"Caret", + b"Ink", + b"FileAttachment", + b"Sound", +]; + +/// The Locked annotation flag. +const LOCKED: i64 = 128; + +/// One comment of the source: its entry in the page's `/Annots` and its dictionary. +struct Incoming { + entry: Object, + dict: Dict, +} + +/// Copy the comments of `src` onto the pages of `dst`: the comments of page 1 onto page 1, those of +/// page 2 onto page 2, and so on, each at the position it has in `src`. Comments on pages past the +/// last page of `dst` are left out, and so is a reply whose comment is left out. Each copy keeps its +/// appearance, its pop-up, its replies and its layer. Links, form fields and redaction marks are +/// not copied. +/// +/// A comment named (`/NM`) like one already on its page replaces that one, which keeps its object +/// so replies to it stay attached. A locked comment, or an annotation of that name that is no +/// comment (a link, a redaction mark, a form widget), is not replaced: the copy is added beside it +/// without the name. Returns the number of comments copied. +pub fn import_comments(dst: &mut Document, src: &Document) -> Result { + let from = walk(src)?; + let to = walk(dst)?; + let pages: Vec<(ObjRef, ObjRef)> = from.iter().zip(&to).map(|((s, _), (d, _))| (*s, *d)).collect(); + let mut incoming: Vec> = pages.iter().map(|(s, _)| page_comments(src, *s)).collect(); + let kept = threads_kept(&incoming); + for list in &mut incoming { + list.retain(|c| c.dict.get(b"IRT").is_none_or(|irt| irt.as_ref().is_some_and(|p| kept.contains(&p)))); + } + let mut copier = Copier::new(src, HashMap::new(), pages.iter().copied().collect()); + // Every comment's object is chosen before any is copied, so a comment that another one reaches + // through `/IRT` is copied into the object chosen for it by name. + let mut lists: Vec> = Vec::new(); + let mut planned: Vec<(usize, ObjRef, Dict)> = Vec::new(); + for (i, ((_, page), list)) in pages.iter().zip(incoming).enumerate() { + if list.is_empty() { + lists.push(None); + continue; + } + let mut annots = PageAnnots::read(dst, *page); + for Incoming { entry, dict } in list { + // The same comment listed twice on a page is copied once. + if entry.as_ref().is_some_and(|r| copier.map.contains_key(&r)) { + continue; + } + let (target, dict) = reserve(dst, src, &mut annots, dict); + if let Some(r) = entry.as_ref() { + copier.map.insert(r, target); + } + annots.push(target); + planned.push((i, target, dict)); + } + lists.push(Some(annots)); + } + let mut annot_pages = HashMap::new(); + for (i, target, dict) in &planned { + let copy = copier.copy_value(dst, &Object::Dict(dict.clone()), Some(*target))?; + dst.set(*target, copy); + annot_pages.insert(*target, pages[*i].1); + } + for (i, target, _) in &planned { + if let Some(popup) = dst.get(*target).as_dict().and_then(|d| d.reference(b"Popup")) + && let Some(annots) = lists[*i].as_mut() + { + annots.push(popup); + annot_pages.insert(popup, pages[*i].1); + } + } + // A pop-up that only its `/Parent` ties to a copied comment comes along too. + for (i, (from, to)) in pages.iter().enumerate() { + let Some(annots) = lists[i].as_mut() else { continue }; + for (popup, parent) in page_popups(src, *from) { + if copier.map.contains_key(&popup) || !copier.map.contains_key(&parent) { + continue; + } + let r = dst.add(Object::Null); + copier.map.insert(popup, r); + let copy = copier.copy_value(dst, &src.get(popup), Some(r))?; + dst.set(r, copy); + annots.push(r); + annot_pages.insert(r, *to); + } + } + for ((_, page), annots) in pages.iter().zip(lists) { + if let Some(annots) = annots { + dst.update_dict(*page, |p| p.set(b"Annots".to_vec(), Object::Array(annots.into_list())))?; + } + } + copier.fix_annotations(dst, &annot_pages)?; + register_layers(dst, src, &copier.ocgs)?; + Ok(planned.len()) +} + +/// Choose the object a comment is copied into: the comment of `annots` with its name, or a new +/// object. Returns it with the dictionary to copy. +fn reserve(dst: &mut Document, src: &Document, annots: &mut PageAnnots, mut dict: Dict) -> (ObjRef, Dict) { + let nm = dict.get(b"NM").and_then(|n| src.resolve(n).as_string().map(|s| s.to_text())); + let same = nm.as_deref().and_then(|nm| annots.named(dst, nm)); + // A second incoming comment with the name of one added earlier is added without the name. + let taken = nm.is_some_and(|nm| !annots.added.insert(nm)); + let target = match same { + None if taken => { + dict.remove(b"NM"); + dst.add(Object::Null) + } + Some((r, false)) => { + annots.claimed.insert(r); + // The replaced comment's pop-up goes with it: the one it names, and any whose `/Parent` + // is the comment. + if let Some(p) = dst.get(r).as_dict().and_then(|d| d.reference(b"Popup")) { + annots.remove(p); + } + for p in annots.popups.get(&r).cloned().unwrap_or_default() { + annots.remove(p); + } + r + } + Some((_, true)) => { + dict.remove(b"NM"); + dst.add(Object::Null) + } + None => dst.add(Object::Null), + }; + (target, dict) +} + +/// The comments of `incoming` that go: a reply goes only with the comment it answers, and that one +/// only with the comment it answers, up to the first comment of the thread. A thread whose replies +/// answer each other in a loop goes, as all of its comments are there. Each comment is visited once. +fn threads_kept(incoming: &[Vec]) -> HashSet { + // For each comment: no parent, a parent comment, or a parent that is no comment of the source. + let parents: HashMap>> = + incoming.iter().flatten().filter_map(|c| Some((c.entry.as_ref()?, c.dict.get(b"IRT").map(Object::as_ref)))).collect(); + let mut known: HashMap = HashMap::new(); + for &start in parents.keys() { + let (mut path, mut on_path) = (Vec::new(), HashSet::new()); + let mut at = start; + let kept = loop { + if let Some(&k) = known.get(&at) { + break k; + } + if !on_path.insert(at) { + break true; + } + match parents.get(&at) { + None => break false, + Some(None) => { + path.push(at); + break true; + } + Some(Some(None)) => { + path.push(at); + break false; + } + Some(Some(Some(parent))) => { + path.push(at); + at = *parent; + } + } + }; + for r in path { + known.insert(r, kept); + } + } + known.into_iter().filter_map(|(r, k)| k.then_some(r)).collect() +} + +/// The comments in `page`'s `/Annots`, in order. +fn page_comments(src: &Document, page: ObjRef) -> Vec { + let list = src.get(page).as_dict().and_then(|p| p.get(b"Annots").cloned()).map(|a| src.resolve(&a)).and_then(|a| a.as_array().cloned()); + list.unwrap_or_default() + .into_iter() + .filter_map(|entry| { + let dict = src.resolve(&entry).as_dict().cloned()?; + let subtype = dict.get(b"Subtype").map(|s| src.resolve(s)).and_then(|s| s.as_name().map(<[u8]>::to_vec))?; + COMMENTS.contains(&subtype.as_slice()).then_some(Incoming { entry, dict }) + }) + .collect() +} + +/// The pop-ups in `page`'s `/Annots` with the comment each belongs to: (pop-up, its `/Parent`). +fn page_popups(src: &Document, page: ObjRef) -> Vec<(ObjRef, ObjRef)> { + let list = src.get(page).as_dict().and_then(|p| p.get(b"Annots").cloned()).map(|a| src.resolve(&a)).and_then(|a| a.as_array().cloned()); + list.unwrap_or_default() + .iter() + .filter_map(|entry| { + let r = entry.as_ref()?; + let d = src.get(r).as_dict().cloned()?; + (d.name(b"Subtype") == Some(b"Popup".as_slice())).then(|| d.reference(b"Parent").map(|p| (r, p)))? + }) + .collect() +} + +/// The annotation in `annots` named `nm` (compared as text, whatever the encoding), and whether it +/// must not be replaced: a locked comment, or an annotation that is no comment, such as a link, a +/// redaction mark or a form widget. +/// A page's `/Annots` while comments are added to it, indexed so that placing each comment does not +/// scan the whole list. +struct PageAnnots { + list: Vec, + members: HashSet, + /// The annotations on the page before the import, by `/NM`, in list order. + names: HashMap>, + /// The pop-ups on the page before the import, by `/Parent`. + popups: HashMap>, + removed: HashSet, + /// The annotations that an incoming comment has already replaced. + claimed: HashSet, + /// The names of the incoming comments placed so far. + added: HashSet, +} + +impl PageAnnots { + /// The `/Annots` of `page`, with inline annotation dictionaries made indirect objects. + fn read(dst: &mut Document, page: ObjRef) -> Self { + let list: Vec = dst + .get(page) + .as_dict() + .and_then(|p| p.get(b"Annots").cloned()) + .map(|a| dst.resolve(&a)) + .and_then(|a| a.as_array().cloned()) + .unwrap_or_default(); + let list: Vec = list + .into_iter() + .map(|a| match a { + Object::Dict(_) => Object::Ref(dst.add(a)), + a => a, + }) + .collect(); + let (mut members, mut names, mut popups) = (HashSet::new(), HashMap::>::new(), HashMap::>::new()); + for r in list.iter().filter_map(Object::as_ref) { + members.insert(r); + let Some(d) = dst.get(r).as_dict().cloned() else { continue }; + if let Some(name) = d.get(b"NM").and_then(|n| dst.resolve(n).as_string().map(|s| s.to_text())) { + names.entry(name).or_default().push(r); + } + if d.name(b"Subtype") == Some(b"Popup".as_slice()) + && let Some(parent) = d.reference(b"Parent") + { + popups.entry(parent).or_default().push(r); + } + } + PageAnnots { list, members, names, popups, removed: HashSet::new(), claimed: HashSet::new(), added: HashSet::new() } + } + + /// Add `r` at the end, unless the page already lists it. + fn push(&mut self, r: ObjRef) { + if self.removed.remove(&r) { + self.list.retain(|a| a.as_ref() != Some(r)); + } + if self.members.insert(r) { + self.list.push(Object::Ref(r)); + } + } + + /// Take every entry for `r` off the page. + fn remove(&mut self, r: ObjRef) { + if self.members.remove(&r) { + self.removed.insert(r); + } + } + + /// The first annotation on the page named `nm`, and whether it must be kept: it is locked, or + /// it is no comment. + fn named(&self, dst: &Document, nm: &str) -> Option<(ObjRef, bool)> { + let mut listed = self.names.get(nm)?.iter().filter(|r| self.members.contains(r)); + let first = *listed.clone().next()?; + // A second incoming comment with the name of one already replaced is added beside it. + let Some(&r) = listed.find(|r| !self.claimed.contains(r)) else { return Some((first, true)) }; + let d = dst.get(r).as_dict().cloned()?; + let flags = d.get(b"F").and_then(|f| dst.resolve(f).as_int()).unwrap_or(0); + let subtype = d.get(b"Subtype").map(|s| dst.resolve(s)); + let comment = subtype.as_deref().and_then(Object::as_name).is_some_and(|s| COMMENTS.contains(&s)); + Some((r, flags & LOCKED != 0 || !comment || d.contains(b"FT"))) + } + + /// The new `/Annots`. + fn into_list(self) -> Vec { + let removed = self.removed; + self.list.into_iter().filter(|a| a.as_ref().is_none_or(|r| !removed.contains(&r))).collect() + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use pdfcraft_cos::{SaveOptions, write_full}; + + use super::*; + + fn build(bodies: &[&str]) -> Document { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, b) in bodies.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{b}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", bodies.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", bodies.len() + 1).as_bytes()); + Document::open(Arc::new(out)).expect("opens") + } + + /// The reviewer's copy: three pages of comments, a link and a pop-up among them. + fn reviewed() -> Document { + build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R 13 0 R 7 0 R 8 0 R 9 0 R 15 0 R 16 0 R] >>", + "<< /Type /Page /Parent 2 0 R /Annots [10 0 R 11 0 R] >>", + "<< /Type /Page /Parent 2 0 R /Annots [12 0 R] >>", + "<< /Type /Annot /Subtype /Text /NM (note-1) /Contents (from the reviewer) /Rect [10 10 30 30] /Popup 13 0 R /P 3 0 R >>", + "<< /Type /Annot /Subtype /Text /IRT 6 0 R /Contents (a reply) /Rect [10 10 30 30] /P 3 0 R >>", + "<< /Type /Annot /Subtype /Stamp /Name /Approved /Rect [50 50 150 100] /AP << /N 14 0 R >> /P 3 0 R >>", + "<< /Type /Annot /Subtype /Text /IRT 12 0 R /Contents (about page 3) /Rect [10 40 30 60] /P 3 0 R >>", + "<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] /A << /S /URI /URI (https://example.com/) >> /P 4 0 R >>", + "<< /Type /Annot /Subtype /Highlight /Rect [0 0 50 20] /QuadPoints [0 20 50 20 0 0 50 0] /Contents (marked) /P 4 0 R >>", + "<< /Type /Annot /Subtype /Square /Rect [0 0 100 100] /P 5 0 R >>", + "<< /Type /Annot /Subtype /Popup /Rect [40 40 200 120] /Parent 6 0 R /P 3 0 R >>", + "<< /Type /XObject /Subtype /Form /BBox [0 0 100 50] /Length 13 >>\nstream\n0 0 100 50 re\nendstream", + "<< /Type /Annot /Subtype /Text /NM (locked) /Contents (the reviewer's) /Rect [80 10 100 30] /P 3 0 R >>", + "<< /Type /Annot /Subtype /Redact /Rect [0 200 20 220] /F 2 /P 3 0 R >>", + ]) + } + + /// The document receiving the comments: two pages, three comments of its own on page 1. + fn original() -> Document { + build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R 6 0 R 7 0 R 8 0 R] >>", + "<< /Type /Page /Parent 2 0 R >>", + "<< /Type /Annot /Subtype /Text /NM (note-1) /Contents (older text) /Rect [10 10 30 30] /Popup 8 0 R /P 3 0 R >>", + "<< /Type /Annot /Subtype /Text /NM (locked) /F 128 /Contents (mine) /Rect [80 10 100 30] /P 3 0 R >>", + "<< /Type /Annot /Subtype /Square /NM (keep) /Rect [100 100 200 200] /P 3 0 R >>", + "<< /Type /Annot /Subtype /Popup /Rect [40 40 200 120] /Parent 5 0 R /P 3 0 R >>", + ]) + } + + fn page_annots(doc: &Document, page: usize) -> Vec<(ObjRef, Dict)> { + let (p, _) = walk(doc).unwrap()[page].clone(); + let list = + doc.get(p).as_dict().and_then(|d| d.get(b"Annots").cloned()).map(|a| doc.resolve(&a).as_array().cloned().unwrap()).unwrap_or_default(); + list.iter().map(|a| (a.as_ref().unwrap(), doc.resolve(a).as_dict().cloned().unwrap())).collect() + } + + fn text(d: &Dict, key: &[u8]) -> Option { + d.get(key).and_then(|v| v.as_string()).map(|s| s.to_text()) + } + + fn by_contents<'a>(list: &'a [(ObjRef, Dict)], contents: &str) -> &'a (ObjRef, Dict) { + list.iter().find(|(_, d)| text(d, b"Contents").as_deref() == Some(contents)).unwrap_or_else(|| panic!("no comment {contents:?}")) + } + + #[test] + fn comments_of_another_pdf_land_on_the_pages_with_the_same_numbers() { + let mut doc = original(); + let src = reviewed(); + assert_eq!(import_comments(&mut doc, &src).unwrap(), 5, "four comments on page 1 and the highlight on page 2"); + let pages: Vec = walk(&doc).unwrap().into_iter().map(|(p, _)| p).collect(); + let one = page_annots(&doc, 0); + for (_, d) in &one { + assert_eq!(d.reference(b"P"), Some(pages[0]), "each annotation names the page it is on"); + } + + // The note named like one already there replaces it in place, with its new pop-up. + let (note, d) = by_contents(&one, "from the reviewer"); + assert_eq!(*note, ObjRef::new(5, 0), "the replaced comment keeps its object"); + let popup = d.reference(b"Popup").unwrap(); + assert_ne!(popup, ObjRef::new(8, 0)); + assert_eq!(doc.get(popup).as_dict().unwrap().reference(b"Parent"), Some(*note)); + let popups: Vec = one.iter().filter(|(_, d)| d.name(b"Subtype") == Some(b"Popup".as_slice())).map(|(r, _)| *r).collect(); + assert_eq!(popups, [popup], "the replaced note's pop-up goes, the copy's is listed once"); + assert_eq!(by_contents(&one, "a reply").1.reference(b"IRT"), Some(*note), "the reply answers the note"); + + // The locked comment of that name stays; the copy is added beside it, unnamed. + assert_eq!(text(&by_contents(&one, "mine").1, b"NM").as_deref(), Some("locked")); + assert_eq!(text(&by_contents(&one, "the reviewer's").1, b"NM"), None); + assert!(one.iter().any(|(_, d)| text(d, b"NM").as_deref() == Some("keep")), "the document's own comments stay"); + + // The stamp keeps its appearance; the redaction mark is not copied. + let (_, stamp) = one.iter().find(|(_, d)| d.name(b"Subtype") == Some(b"Stamp".as_slice())).unwrap(); + let ap = doc.resolve(stamp.get(b"AP").unwrap()).as_dict().cloned().unwrap(); + assert!(matches!(&*doc.resolve(ap.get(b"N").unwrap()), Object::Stream(_)), "the appearance stream is copied"); + assert!(one.iter().all(|(_, d)| d.name(b"Subtype") != Some(b"Redact".as_slice()))); + + // Page 3 is past the document's last page: its square and the reply to it are left out. + assert!(one.iter().all(|(_, d)| text(d, b"Contents").as_deref() != Some("about page 3"))); + assert_eq!(one.len(), 7, "three of the document's own, three new copies and the note's new pop-up"); + + // Page 2 takes the highlight, not the link. + let two = page_annots(&doc, 1); + assert_eq!(two.len(), 1); + assert_eq!(two[0].1.name(b"Subtype"), Some(b"Highlight".as_slice())); + assert_eq!(two[0].1.reference(b"P"), Some(pages[1])); + + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert_eq!((page_annots(&saved, 0).len(), page_annots(&saved, 1).len()), (7, 1), "a saved copy reads back the same"); + } + + #[test] + fn a_reply_on_an_earlier_page_reaches_its_comment_by_name() { + // Page 1 holds a reply to a note on page 2, named like the document's own note there. + let src = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>", + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R] >>", + "<< /Type /Annot /Subtype /Text /IRT 6 0 R /Contents (a reply) /Rect [10 10 30 30] /P 3 0 R >>", + "<< /Type /Annot /Subtype /Text /NM (note-2) /Contents (from the reviewer) /Rect [10 10 30 30] /P 4 0 R >>", + // A pop-up that only its /Parent ties to the note. + "<< /Type /Annot /Subtype /Popup /Rect [50 50 210 130] /Open true /Parent 6 0 R /P 4 0 R >>", + ]); + let mut doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R >>", + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R 6 0 R] >>", + // The same name in UTF-16BE, and a pop-up the note does not name. + "<< /Type /Annot /Subtype /Text /NM /Contents (older text) /Rect [10 10 30 30] /P 4 0 R >>", + "<< /Type /Annot /Subtype /Popup /Rect [40 40 200 120] /Parent 5 0 R /P 4 0 R >>", + ]); + assert_eq!(import_comments(&mut doc, &src).unwrap(), 2); + let two = page_annots(&doc, 1); + assert_eq!(two.len(), 2, "the note replaces the document's own, whose pop-up goes, and its own pop-up comes"); + assert_eq!((two[0].0, text(&two[0].1, b"Contents").as_deref()), (ObjRef::new(5, 0), Some("from the reviewer"))); + assert_ne!(two[1].0, ObjRef::new(6, 0)); + assert_eq!(two[1].1.reference(b"Parent"), Some(ObjRef::new(5, 0))); + assert_eq!(two[1].1.get(b"Open"), Some(&Object::Bool(true))); + assert_eq!(two[1].1.reference(b"P"), Some(walk(&doc).unwrap()[1].0)); + let one = page_annots(&doc, 0); + assert_eq!(one.len(), 1); + assert_eq!(one[0].1.reference(b"IRT"), Some(ObjRef::new(5, 0)), "the reply answers the replaced note"); + } + + #[test] + fn a_comment_keeps_its_layer_and_the_layer_its_visibility() { + let src = build(&[ + "<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [6 0 R] /D << /OFF [6 0 R] >> >> >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>", + "<< >>", + "<< /Type /Annot /Subtype /Square /Rect [0 0 50 50] /OC 6 0 R /P 3 0 R >>", + "<< /Type /OCG /Name (Review) >>", + ]); + let mut doc = original(); + import_comments(&mut doc, &src).unwrap(); + let (_, square) = + page_annots(&doc, 0).into_iter().find(|(_, d)| d.name(b"Subtype") == Some(b"Square".as_slice()) && d.contains(b"OC")).unwrap(); + let layer = square.reference(b"OC").unwrap(); + let catalog = doc.get(doc.root().unwrap()).as_dict().cloned().unwrap(); + let props = doc.resolve(catalog.get(b"OCProperties").unwrap()).as_dict().cloned().unwrap(); + assert_eq!(props.get(b"OCGs"), Some(&Object::Array(vec![Object::Ref(layer)]))); + let config = props.get(b"D").and_then(Object::as_dict).cloned().unwrap(); + assert_eq!(config.get(b"OFF"), Some(&Object::Array(vec![Object::Ref(layer)])), "the layer stays hidden"); + } + + #[test] + fn a_layer_hidden_by_the_base_state_stays_hidden() { + let src = build(&[ + "<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [6 0 R] /D << /BaseState /OFF >> >> >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>", + "<< >>", + "<< /Type /Annot /Subtype /Square /Rect [0 0 50 50] /OC 6 0 R /P 3 0 R >>", + "<< /Type /OCG /Name (Review) >>", + ]); + let mut doc = original(); + import_comments(&mut doc, &src).unwrap(); + let (_, square) = + page_annots(&doc, 0).into_iter().find(|(_, d)| d.name(b"Subtype") == Some(b"Square".as_slice()) && d.contains(b"OC")).unwrap(); + let layer = square.reference(b"OC").unwrap(); + let catalog = doc.get(doc.root().unwrap()).as_dict().cloned().unwrap(); + let props = doc.resolve(catalog.get(b"OCProperties").unwrap()).as_dict().cloned().unwrap(); + let config = props.get(b"D").and_then(Object::as_dict).cloned().unwrap(); + assert_eq!(config.get(b"OFF"), Some(&Object::Array(vec![Object::Ref(layer)])), "the layer stays hidden"); + assert_eq!(config.get(b"ON"), Some(&Object::Array(vec![]))); + } + + #[test] + fn a_long_thread_goes_with_its_first_comment_and_a_loop_of_replies_stays() { + // Page 1: replies 6, 7, … each answering the next, the last answering the square on page 3, + // which the document has no page for. Then two replies that answer each other. + let n = 20_000; + let first = 6; + let mut bodies: Vec = + vec!["<< /Type /Catalog /Pages 2 0 R >>".into(), "<< /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 /MediaBox [0 0 300 400] >>".into()]; + let list: String = (first..first + n + 2).map(|o| format!("{o} 0 R ")).collect(); + bodies.push(format!("<< /Type /Page /Parent 2 0 R /Annots [{list}] >>")); + bodies.push("<< /Type /Page /Parent 2 0 R >>".into()); + let square = first + n + 2; + bodies.push(format!("<< /Type /Page /Parent 2 0 R /Annots [{square} 0 R] >>")); + for o in first..first + n { + let parent = if o + 1 == first + n { square } else { o + 1 }; + bodies.push(format!("<< /Type /Annot /Subtype /Text /IRT {parent} 0 R /Rect [0 0 10 10] /P 3 0 R >>")); + } + let (a, b) = (first + n, first + n + 1); + bodies.push(format!("<< /Type /Annot /Subtype /Text /IRT {b} 0 R /Contents (one) /Rect [0 0 10 10] /P 3 0 R >>")); + bodies.push(format!("<< /Type /Annot /Subtype /Text /IRT {a} 0 R /Contents (two) /Rect [0 0 10 10] /P 3 0 R >>")); + bodies.push("<< /Type /Annot /Subtype /Square /Rect [0 0 100 100] /P 5 0 R >>".into()); + let src = build(&bodies.iter().map(String::as_str).collect::>()); + let mut doc = original(); + let started = std::time::Instant::now(); + assert_eq!(import_comments(&mut doc, &src).unwrap(), 2, "only the two replies that answer each other"); + assert!(started.elapsed() < std::time::Duration::from_secs(10), "{:?}", started.elapsed()); + let one = page_annots(&doc, 0); + let (one_ref, one_dict) = by_contents(&one, "one"); + let (two_ref, two_dict) = by_contents(&one, "two"); + assert_eq!((one_dict.reference(b"IRT"), two_dict.reference(b"IRT")), (Some(*two_ref), Some(*one_ref))); + } + + #[test] + fn only_a_comment_is_replaced_by_name() { + let src = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R] >>", + "<< /Type /Annot /Subtype /Text /NM (link-1) /Contents (from the reviewer) /Rect [10 10 30 30] /P 3 0 R >>", + "<< /Type /Annot /Subtype /Square /NM (movie-1) /Contents (a square) /Rect [40 40 60 60] /P 3 0 R >>", + "<< /Type /Annot /Subtype /Text /NM (note-3) /Contents (newer text) /Rect [70 70 90 90] /P 3 0 R >>", + ]); + // A link and a screen annotation named like two of the comments, and a comment whose + // subtype is an indirect object. + let mut doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R] >>", + "<< /Type /Annot /Subtype /Link /NM (link-1) /Rect [0 0 10 10] /A << /S /URI /URI (https://example.com/) >> /P 3 0 R >>", + "<< /Type /Annot /Subtype /Screen /NM (movie-1) /Rect [100 100 200 200] /P 3 0 R >>", + "<< /Type /Annot /Subtype 7 0 R /NM (note-3) /Contents (older text) /Rect [70 70 90 90] /P 3 0 R >>", + "/Text", + ]); + assert_eq!(import_comments(&mut doc, &src).unwrap(), 3); + let one = page_annots(&doc, 0); + assert_eq!(one.len(), 5, "the link and the screen annotation stay, two comments are added and one replaced"); + let (note, d) = by_contents(&one, "newer text"); + assert_eq!((*note, text(d, b"NM").as_deref()), (ObjRef::new(6, 0), Some("note-3")), "the comment is replaced in place"); + assert_eq!((one[0].0, one[0].1.name(b"Subtype")), (ObjRef::new(4, 0), Some(b"Link".as_slice()))); + assert_eq!(text(&one[0].1, b"NM").as_deref(), Some("link-1")); + assert_eq!((one[1].0, one[1].1.name(b"Subtype")), (ObjRef::new(5, 0), Some(b"Screen".as_slice()))); + assert_eq!(text(&one[1].1, b"NM").as_deref(), Some("movie-1")); + assert_eq!(text(&by_contents(&one, "from the reviewer").1, b"NM"), None, "the copy is added without the name"); + assert_eq!(text(&by_contents(&one, "a square").1, b"NM"), None); + } + + #[test] + fn two_comments_with_one_name_replace_one_comment_and_add_the_other() { + let src = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>", + "<< /Type /Annot /Subtype /Text /NM (twin) /Contents (first) /Rect [10 10 30 30] /P 3 0 R >>", + "<< /Type /Annot /Subtype /Text /NM (twin) /Contents (second) /Rect [40 40 60 60] /P 3 0 R >>", + ]); + let mut doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", + "<< /Type /Annot /Subtype /Text /NM (twin) /Contents (older text) /Rect [10 10 30 30] /P 3 0 R >>", + ]); + assert_eq!(import_comments(&mut doc, &src).unwrap(), 2); + let one = page_annots(&doc, 0); + assert_eq!(one.len(), 2, "one comment replaced and one added"); + let (first, d) = by_contents(&one, "first"); + assert_eq!((*first, text(d, b"NM").as_deref()), (ObjRef::new(4, 0), Some("twin"))); + assert_eq!(text(&by_contents(&one, "second").1, b"NM"), None, "the second copy is added without the name"); + } + + #[test] + fn two_new_comments_with_one_name_keep_it_once() { + let src = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>", + "<< /Type /Annot /Subtype /Text /NM (twin) /Contents (first) /Rect [10 10 30 30] /P 3 0 R >>", + "<< /Type /Annot /Subtype /Text /NM (twin) /Contents (second) /Rect [40 40 60 60] /P 3 0 R >>", + ]); + let mut doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R >>", + ]); + assert_eq!(import_comments(&mut doc, &src).unwrap(), 2); + let one = page_annots(&doc, 0); + assert_eq!(one.len(), 2); + assert_eq!(text(&by_contents(&one, "first").1, b"NM").as_deref(), Some("twin")); + assert_eq!(text(&by_contents(&one, "second").1, b"NM"), None, "the second copy is added without the name"); + } + + #[test] + fn an_inline_comment_is_replaced_by_name() { + let src = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", + "<< /Type /Annot /Subtype /Text /NM (inline) /Contents (newer text) /Rect [10 10 30 30] /P 3 0 R >>", + ]); + let mut doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [<< /Type /Annot /Subtype /Text /NM (inline) /Contents (older text) /Rect [10 10 30 30] >>] >>", + ]); + assert_eq!(import_comments(&mut doc, &src).unwrap(), 1); + let one = page_annots(&doc, 0); + assert_eq!(one.len(), 1, "the inline comment is replaced, not kept beside the copy"); + assert_eq!(text(&by_contents(&one, "newer text").1, b"NM").as_deref(), Some("inline")); + } + + #[test] + fn a_pdf_without_comments_copies_none() { + let mut doc = original(); + let before = page_annots(&doc, 0).len(); + let src = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", + "<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] /P 3 0 R >>", + ]); + assert_eq!(import_comments(&mut doc, &src).unwrap(), 0); + assert_eq!(page_annots(&doc, 0).len(), before); + } +} diff --git a/crates/organize/src/import.rs b/crates/organize/src/import.rs index ef11f10007..9f86d6455a 100644 --- a/crates/organize/src/import.rs +++ b/crates/organize/src/import.rs @@ -23,223 +23,21 @@ use pdfcraft_cos::{Dict, Document, ObjRef, Object}; use crate::{INHERITABLE, OrganizeError, pages_root, rebuild, walk}; -/// Keys that must not be followed when copying: they point back up or across the source -/// document, and following them would drag in unrelated objects. -const SKIP_ON_PAGE: &[&[u8]] = &[b"Parent", b"B", b"StructParents", b"Thumb", b"PieceInfo"]; -const SKIP_ON_ANNOT: &[&[u8]] = &[b"StructParent", b"P"]; - -/// How deep a copy follows references and nested arrays and dictionaries, counted together. -/// Real documents stay well under this (a page's resources, fonts and descriptors, nested -/// form XObjects and field trees are a few levels each); a chain of thousands of indirect -/// objects, each shallow enough for the parser, would otherwise exhaust the stack (#1800). -const MAX_COPY_DEPTH: usize = 256; - -/// Keys that stay on the widget when a combined field/widget is split (#1810): the annotation's -/// appearance, placement and page. Field state (name, value, flags, default appearance) moves to -/// the new parent field, where the widgets inherit it. -const WIDGET_KEYS: &[&[u8]] = &[ - b"Type", - b"Subtype", - b"Rect", - b"P", - b"AP", - b"AS", - b"MK", - b"BS", - b"Border", - b"C", - b"CA", - b"F", - b"H", - b"R", - b"NM", - b"M", - b"StructParent", - b"StructParents", - b"OC", - b"Tabs", - b"A", - b"Contents", - b"BM", - b"Lang", - b"AF", -]; - -struct Copier<'a> { - src: &'a Document, - /// Current recursion depth, bounded by [`MAX_COPY_DEPTH`]. - depth: usize, - map: HashMap, - /// Source page → destination page, for rewriting `/P` and destinations. - pages: HashMap, - /// Destination objects that are annotation dictionaries (fixed up after copying). - annots: Vec, - /// Top-level form fields reached through widgets. - fields: Vec, - /// Optional content groups copied: (source, destination). - ocgs: Vec<(ObjRef, ObjRef)>, -} - -impl<'a> Copier<'a> { - fn new(src: &'a Document, map: HashMap, pages: HashMap) -> Self { - Copier { src, depth: 0, map, pages, annots: Vec::new(), fields: Vec::new(), ocgs: Vec::new() } - } - - /// Copy an indirect object (once), returning its destination reference. - fn copy_ref(&mut self, dst: &mut Document, r: ObjRef) -> Result { - if let Some(d) = self.map.get(&r) { - return Ok(*d); - } - // Reserve the number first so cycles (field ↔ widget) terminate. - let new = dst.add(Object::Null); - self.map.insert(r, new); - let obj = self.src.get(r); - if obj.as_dict().is_some_and(|d| d.name(b"Type") == Some(b"OCG")) { - self.ocgs.push((r, new)); - } - let copied = self.copy_value(dst, &obj, Some(new))?; - dst.set(new, copied); - Ok(new) - } - - /// One level deeper into the object graph, or the refusal when there are too many. - fn descend(&mut self) -> Result<(), OrganizeError> { - if self.depth >= MAX_COPY_DEPTH { - return Err(OrganizeError::Invalid(format!("the pages refer to objects nested more than {MAX_COPY_DEPTH} levels deep"))); - } - self.depth += 1; - Ok(()) - } - - fn copy_value(&mut self, dst: &mut Document, o: &Object, this: Option) -> Result { - if !matches!(o, Object::Ref(_) | Object::Array(_) | Object::Dict(_) | Object::Stream(_)) { - return Ok(o.clone()); - } - self.descend()?; - let copied = match o { - Object::Ref(r) => self.copy_ref(dst, *r).map(Object::Ref), - Object::Array(a) => a.iter().map(|x| self.copy_value(dst, x, None)).collect::, _>>().map(Object::Array), - Object::Dict(d) => self.copy_dict(dst, d, this).map(Object::Dict), - Object::Stream(s) => self.copy_dict(dst, &s.dict, None).map(|dict| { - let mut s = s.clone(); - s.dict = dict; - Object::Stream(s) - }), - other => Ok(other.clone()), - }; - self.depth = self.depth.saturating_sub(1); - copied - } - - fn copy_dict(&mut self, dst: &mut Document, d: &Dict, this: Option) -> Result { - let is_annot = d.name(b"Type") == Some(b"Annot") || (d.contains(b"Subtype") && d.contains(b"Rect") && !d.contains(b"Type")); - let is_field = d.contains(b"FT") || (d.contains(b"T") && d.contains(b"Kids")); - let mut out = Dict::new(); - for (k, v) in d.iter() { - if is_annot && SKIP_ON_ANNOT.contains(&k.as_slice()) { - continue; - } - // Destinations are rewritten afterwards (they may target pages copied later). - if is_annot && (k.as_slice() == b"Dest" || k.as_slice() == b"A") { - out.set(k.clone(), v.clone()); - continue; - } - out.set(k.clone(), self.copy_value(dst, v, None)?); - } - if let Some(this) = this { - if is_annot { - self.annots.push(this); - } - // A field (or widget) without a parent is a top-level field. - if is_field && !d.contains(b"Parent") { - self.fields.push(this); - } - } - Ok(out) - } - - /// Map a source destination (explicit array or reference to one) to the destination - /// document; `None` if it targets a page that was not copied. - fn map_dest(&self, dest: &Object) -> Option { - self.map_dest_depth(dest, 0) - } - - fn map_dest_depth(&self, dest: &Object, depth: u8) -> Option { - if depth > 4 { - return None; - } - match dest { - Object::Name(n) => self.map_dest_depth(&named_dest(self.src, n)?, depth + 1), - Object::String(s) => self.map_dest_depth(&named_dest(self.src, &s.bytes)?, depth + 1), - Object::Ref(_) => self.map_dest_depth(&self.src.resolve(dest), depth + 1), - Object::Dict(d) => self.map_dest_depth(d.get(b"D")?, depth + 1), - Object::Array(a) => { - let page = a.first()?.as_ref()?; - let new_page = *self.pages.get(&page)?; - let mut a = a.clone(); - a[0] = Object::Ref(new_page); - Some(Object::Array(a)) - } - _ => None, - } - } - - /// Rewrite `/P`, `/Dest` and GoTo actions on copied annotations. - fn fix_annotations(&mut self, dst: &mut Document, annot_pages: &HashMap) -> Result<(), OrganizeError> { - for &a in &std::mem::take(&mut self.annots) { - let src_dict = dst.get(a).as_dict().cloned(); - let Some(d) = src_dict else { continue }; - let mut d = d; - if let Some(p) = annot_pages.get(&a) { - d.set(b"P".to_vec(), Object::Ref(*p)); - } - if let Some(dest) = d.get(b"Dest").cloned() { - let resolved = self.src.resolve(&dest); - match self.map_dest(&resolved) { - Some(nd) => d.set(b"Dest".to_vec(), nd), - None => { - d.remove(b"Dest"); - } - } - } - if let Some(action) = d.get(b"A").cloned() { - let action = self.src.resolve(&action); - if let Some(ad) = action.as_dict() { - if ad.name(b"S") == Some(b"GoTo") { - let target = ad.get(b"D").map(|x| self.src.resolve(x)); - match target.and_then(|t| self.map_dest(&t)) { - Some(nd) => { - let mut na = Dict::new(); - na.set(b"S".to_vec(), Object::name("GoTo")); - na.set(b"D".to_vec(), nd); - d.set(b"A".to_vec(), Object::Dict(na)); - } - None => { - d.remove(b"A"); - } - } - } else { - // Other actions (URI, Launch, JavaScript…) carry no page references. The - // copier lends its map out and takes it back with the new copies in it, - // so actions sharing an object share its one copy. - let mut c = Copier::new(self.src, std::mem::take(&mut self.map), HashMap::new()); - let copied = c.copy_value(dst, &Object::Dict(ad.clone()), None); - self.map = c.map; - d.set(b"A".to_vec(), copied?); - } - } - } - dst.set(a, Object::Dict(d)); - } - Ok(()) - } -} - -/// Copy `o` from `src` into `dst`, reusing the copies listed in `map` (source → destination), so -/// an object the copied pages already brought along (an ICC profile) is not stored twice. -pub(crate) fn copy_object(dst: &mut Document, src: &Document, o: &Object, map: HashMap) -> Result { - Copier::new(src, map, HashMap::new()).copy_value(dst, o, None) -} +mod carry; +mod combine; +mod copy; +mod fields; + +pub(crate) use carry::set_attachments; +use carry::{Mark, source_outline, write_outline_level}; +pub use combine::{ + CombineOutcome, Combiner, Run, SplitBy, combine, combine_grouped, combine_grouped_with, combine_selected, combine_selected_with, combine_with, + extract_pages, split, split_ranges, +}; +use copy::SKIP_ON_PAGE; +pub(crate) use copy::{Copier, copy_object}; +use fields::{add_kid, field_and_widget, register_fields, resolve_field_conflicts, split_own_field}; +pub use fields::{combine_field_conflicts, field_name_conflicts}; /// How colliding fully-qualified field names are resolved when pages are imported. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] @@ -634,18 +432,22 @@ pub(crate) fn name_tree_entries(src: &Document, node: &Object, depth: u8, seen: /// Register copied optional content groups in the destination's `/OCProperties`, keeping each /// layer's default visibility from the source. -fn register_layers(dst: &mut Document, src: &Document, ocgs: &[(ObjRef, ObjRef)]) -> Result<(), OrganizeError> { +pub(crate) fn register_layers(dst: &mut Document, src: &Document, ocgs: &[(ObjRef, ObjRef)]) -> Result<(), OrganizeError> { if ocgs.is_empty() { return Ok(()); } - let src_off: Vec = src + let src_config: Dict = src .root() .and_then(|r| src.get(r).as_dict().cloned()) .and_then(|c| c.get(b"OCProperties").map(|o| src.resolve(o))) .and_then(|o| o.as_dict().and_then(|d| d.get(b"D")).map(|d| src.resolve(d))) - .and_then(|d| d.as_dict().and_then(|d| d.get(b"OFF")).map(|o| src.resolve(o))) - .and_then(|o| o.as_array().cloned()) + .and_then(|d| d.as_dict().cloned()) .unwrap_or_default(); + let src_list = |k: &[u8]| src_config.get(k).map(|v| src.resolve(v)).and_then(|v| v.as_array().cloned()).unwrap_or_default(); + let (src_on, src_off) = (src_list(b"ON"), src_list(b"OFF")); + // With `/BaseState /OFF`, a layer is hidden unless `/ON` lists it (§8.11.4.3, Table 101). + let base_off = src_config.get(b"BaseState").map(|b| src.resolve(b)).is_some_and(|b| b.as_name() == Some(b"OFF".as_slice())); + let hidden = |r: &Object| src_off.contains(r) || (base_off && !src_on.contains(r)); let root = dst.root().ok_or(OrganizeError::NoPageTree)?; let catalog = dst.get(root).as_dict().cloned().ok_or(OrganizeError::NoPageTree)?; let (props_ref, mut props) = match catalog.get(b"OCProperties") { @@ -664,7 +466,7 @@ fn register_layers(dst: &mut Document, src: &Document, ocgs: &[(ObjRef, ObjRef)] } all.push(r.clone()); order.push(r.clone()); - if src_off.contains(&Object::Ref(*from)) { off.push(r) } else { on.push(r) } + if hidden(&Object::Ref(*from)) { off.push(r) } else { on.push(r) } } props.set(b"OCGs".to_vec(), Object::Array(all)); config.set(b"ON".to_vec(), Object::Array(on)); @@ -678,833 +480,6 @@ fn register_layers(dst: &mut Document, src: &Document, ocgs: &[(ObjRef, ObjRef)] Ok(()) } -/// Rename the top-level fields about to be registered in `dst` whose fully-qualified names -/// (`parent.T.child`) are already taken, so each imported field stays its own field instead of -/// blurring into the destination's field of the same name (fields of one fully-qualified name -/// are one field). A colliding node's own `/T` becomes the first free `Name-1`, `Name-2`… -/// variant, so everything below it keeps its place in the tree. Returns what was renamed -/// (old fully-qualified name → new one), in order. -fn resolve_field_conflicts(dst: &mut Document, fields: &[ObjRef]) -> Result, OrganizeError> { - let mut taken = taken_field_names(dst)?; - let planned = plan_renames(dst, fields, &mut taken); - let mut renamed = Vec::new(); - for (node, new_t, old, new) in planned { - dst.update_dict(node, |d| d.set(b"T".to_vec(), Object::String(pdfcraft_cos::PdfString::text(&new_t))))?; - renamed.push((old, new)); - } - Ok(renamed) -} - -/// What importing `src` into `dst` would rename, computed without changing either document: -/// each fully-qualified name of `src`'s fields that `dst` already uses (or that an earlier -/// field of `src` itself uses) is renamed to the first free variant, exactly as the import -/// would. Unreadable structure previews as nothing: the import itself will say why it failed. -pub fn field_name_conflicts(dst: &Document, src: &Document) -> Vec<(String, String)> { - let Ok(mut taken) = taken_field_names(dst) else { return Vec::new() }; - let Ok(roots) = field_roots(src) else { return Vec::new() }; - plan_renames(src, &roots, &mut taken).into_iter().map(|(_, _, old, new)| (old, new)).collect() -} - -/// What combining `sources`, in order, would rename in each one: the whole list is simulated -/// with the same first-free-variant rule the combine uses, so a later file's preview sees the -/// earlier files' renames. Unreadable structure previews as nothing (the combine itself will -/// say why it fails); non-colliding names still count as taken for the files after them. -pub fn combine_field_conflicts(sources: &[&Document]) -> Vec> { - let mut taken = std::collections::HashSet::new(); - sources - .iter() - .map(|src| match field_roots(src) { - Ok(roots) => plan_renames(src, &roots, &mut taken).into_iter().map(|(_, _, old, new)| (old, new)).collect(), - Err(_) => Vec::new(), - }) - .collect() -} - -/// Walk field trees `roots` in `doc` and plan the renames that would keep every name separate -/// from `taken`: each node's fully-qualified name joins `taken` (so a later field of the same -/// import collides with an earlier one), and a node already taken is renamed to the first free -/// variant of its own `/T`. Names are compared and renamed as decoded text, not as stored -/// bytes, so one name written in two encodings (PDFDocEncoding, UTF-16BE) is one name. -/// Read-only: returns (node, new `/T`, old name, new name). Cycle- and depth-safe. -fn plan_renames(doc: &Document, roots: &[ObjRef], taken: &mut std::collections::HashSet) -> Vec<(ObjRef, String, String, String)> { - let mut planned = Vec::new(); - let mut walk = RenameWalk::default(); - for &root in roots { - plan_renames_under(doc, root, "", taken, &mut walk, 0, &mut planned); - } - planned -} - -/// What one [`plan_renames`] call remembers: the nodes it visited, and for each colliding name -/// the suffix to try next. -#[derive(Default)] -struct RenameWalk { - seen: HashSet, - next: HashMap, -} - -/// One node of [`plan_renames`]. -fn plan_renames_under( - doc: &Document, - node: ObjRef, - prefix: &str, - taken: &mut std::collections::HashSet, - walk: &mut RenameWalk, - depth: u8, - planned: &mut Vec<(ObjRef, String, String, String)>, -) { - if depth > 32 || !walk.seen.insert(node) { - return; - } - let Some(d) = doc.get(node).as_dict().cloned() else { return }; - let mut name = d.get(b"T").and_then(|t| doc.resolve(t).as_string().map(|s| s.to_text())).unwrap_or_default(); - if !name.is_empty() { - let fqn = qualified(prefix, &name); - if taken.contains(&fqn) { - // `taken` only grows, so every suffix below the last one handed out for this name is - // still taken: resume there instead of at 1. - let mut n = walk.next.get(&fqn).copied().unwrap_or(1); - let mut variant; - while { - #[cfg(test)] - crate::work(1); - variant = format!("{name}-{n}"); - taken.contains(&qualified(prefix, &variant)) - } { - n = n.saturating_add(1); - } - walk.next.insert(fqn.clone(), n.saturating_add(1)); - let new_fqn = qualified(prefix, &variant); - planned.push((node, variant.clone(), fqn, new_fqn.clone())); - taken.insert(new_fqn); - name = variant; - } else { - taken.insert(fqn); - } - } - let prefix = qualified_optional(prefix, &name); - let kids = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default(); - for kid in kids.iter().filter_map(|k| k.as_ref()) { - plan_renames_under(doc, kid, &prefix, taken, walk, depth + 1, planned); - } -} - -/// The field names in use in `dst`: every name in its form, and every name hanging only off a -/// page's widgets — a field the form never lists still owns its name. -fn taken_field_names(dst: &Document) -> Result, OrganizeError> { - let mut taken = std::collections::HashSet::new(); - let mut seen = std::collections::HashSet::new(); - for root in field_roots(dst)? { - collect_names(dst, root, "", &mut seen, 0, &mut taken); - } - Ok(taken) -} - -/// A document's field trees, wherever they are rooted: the `/AcroForm /Fields` entries, and -/// the fields behind widgets on pages (climbed to their root, for forms that never registered -/// them). -fn field_roots(doc: &Document) -> Result, OrganizeError> { - let mut roots: Vec = doc - .root() - .and_then(|r| doc.get(r).as_dict().cloned()) - .and_then(|c| c.get(b"AcroForm").map(|f| doc.resolve(f))) - .and_then(|f| f.as_dict().cloned()) - .and_then(|f| f.get(b"Fields").map(|l| doc.resolve(l))) - .and_then(|l| l.as_array().cloned()) - .unwrap_or_default() - .iter() - .filter_map(|f| f.as_ref()) - .collect(); - for (page, _) in walk(doc)? { - let Some(d) = doc.get(page).as_dict().cloned() else { continue }; - let annots = d.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default(); - for a in annots.iter() { - let Some(w) = doc.resolve(a).as_dict().cloned() else { continue }; - if w.name(b"Subtype") != Some(b"Widget") { - continue; - } - // Walk up to the field the widget belongs to, however deep it sits; a widget that - // is its own field is its own root. - let Some(mut root) = a.as_ref() else { continue }; - let mut up = 0; - while let Some(parent) = doc.get(root).as_dict().and_then(|p| p.reference(b"Parent")) { - if up >= 32 || root == parent { - break; - } - root = parent; - up += 1; - } - roots.push(root); - } - } - Ok(roots) -} - -/// Record the fully-qualified names of one field tree. Cycle- and depth-safe. -fn collect_names( - dst: &Document, - node: ObjRef, - prefix: &str, - seen: &mut std::collections::HashSet, - depth: u8, - out: &mut std::collections::HashSet, -) { - if depth > 32 || !seen.insert(node) { - return; - } - let Some(d) = dst.get(node).as_dict().cloned() else { return }; - let name = d.get(b"T").and_then(|t| dst.resolve(t).as_string().map(|s| s.to_text())).unwrap_or_default(); - let prefix = qualified_optional(prefix, &name); - if !name.is_empty() { - out.insert(prefix.clone()); - } - let kids = d.get(b"Kids").map(|k| dst.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default(); - for kid in kids.iter().filter_map(|k| k.as_ref()) { - collect_names(dst, kid, &prefix, seen, depth + 1, out); - } -} - -/// `prefix.name`, or `name` at the top level. -fn qualified(prefix: &str, name: &str) -> String { - if prefix.is_empty() { name.to_string() } else { format!("{prefix}.{name}") } -} - -/// The prefix a node's children hang under: its own name, when it has one. -fn qualified_optional(prefix: &str, name: &str) -> String { - if name.is_empty() { prefix.to_string() } else { qualified(prefix, name) } -} - -/// Add `kid` to `field`'s `/Kids`, in the array object itself when `/Kids` is one, so the field -/// keeps its other widgets. -fn add_kid(dst: &mut Document, field: ObjRef, kid: ObjRef) -> Result<(), OrganizeError> { - match dst.get(field).as_dict().and_then(|f| f.get(b"Kids").cloned()) { - Some(Object::Ref(list)) => { - let mut kids = dst.get(list).as_array().cloned().unwrap_or_default(); - kids.push(Object::Ref(kid)); - dst.set(list, Object::Array(kids)); - } - _ => dst.update_dict(field, |f| { - let mut kids = f.get(b"Kids").and_then(|k| k.as_array()).cloned().unwrap_or_default(); - kids.push(Object::Ref(kid)); - f.set(b"Kids".to_vec(), Object::Array(kids)); - })?, - } - Ok(()) -} - -/// PdfCraft patch (#1810): split a widget that doubles as its own field into a parent field -/// holding the field keys (name, value, flags…) and a widget child holding the annotation keys -/// (rectangle, appearance, page). The parent takes the widget's place among the top-level fields; -/// later copies of the page add widget children of it, so every page has its own widget and the -/// field stays one. -fn split_own_field(dst: &mut Document, fields: &mut [ObjRef], widget: ObjRef) { - let Some(d) = dst.get(widget).as_dict().cloned() else { return }; - let (mut field, mut child) = field_and_widget(dst, &d); - let parent = dst.add(Object::Null); - field.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(widget)])); - child.set(b"Parent".to_vec(), Object::Ref(parent)); - dst.set(parent, Object::Dict(field)); - dst.set(widget, Object::Dict(child)); - if let Some(entry) = fields.iter_mut().find(|f| **f == widget) { - *entry = parent; - } -} - -/// A combined field/widget has two additional-action namespaces (ISO 32000-1 tables 194/196). -/// Partition them as well as the ordinary entries, including when copying a later widget. -fn field_and_widget(doc: &Document, d: &Dict) -> (Dict, Dict) { - let (mut field, mut child) = (Dict::new(), Dict::new()); - for (k, v) in d.iter() { - if k.as_slice() == b"AA" - && let Some(actions) = doc.resolve(v).as_dict().cloned() - { - let (mut field_actions, mut widget_actions) = (Dict::new(), Dict::new()); - for (trigger, action) in actions.iter() { - if [b"E".as_slice(), b"X", b"D", b"U", b"Fo", b"Bl", b"PO", b"PC", b"PV", b"PI"].contains(&trigger.as_slice()) { - widget_actions.set(trigger.clone(), action.clone()); - } else { - field_actions.set(trigger.clone(), action.clone()); - } - } - if field_actions.iter().next().is_some() { - field.set(k.clone(), Object::Dict(field_actions)); - } - if widget_actions.iter().next().is_some() { - child.set(k.clone(), Object::Dict(widget_actions)); - } - } else if WIDGET_KEYS.contains(&k.as_slice()) { - child.set(k.clone(), v.clone()); - } else { - field.set(k.clone(), v.clone()); - } - } - (field, child) -} - -/// Add top-level fields to the destination's `/AcroForm /Fields` (creating the form if needed). -fn register_fields(dst: &mut Document, fields: &[ObjRef]) -> Result<(), OrganizeError> { - if fields.is_empty() { - return Ok(()); - } - let root = dst.root().ok_or(OrganizeError::NoPageTree)?; - let catalog = dst.get(root).as_dict().cloned().ok_or(OrganizeError::NoPageTree)?; - let (form_ref, mut form) = match catalog.get(b"AcroForm") { - Some(Object::Ref(r)) => (Some(*r), dst.get(*r).as_dict().cloned().unwrap_or_default()), - Some(Object::Dict(d)) => (None, d.clone()), - _ => (None, Dict::new()), - }; - let mut list = form.get(b"Fields").map(|f| dst.resolve(f)).and_then(|f| f.as_array().cloned()).unwrap_or_default(); - let mut listed: HashSet = list.iter().filter_map(Object::as_ref).collect(); - for f in fields { - #[cfg(test)] - crate::work(1); - if listed.insert(*f) { - list.push(Object::Ref(*f)); - } - } - form.set(b"Fields".to_vec(), Object::Array(list)); - match form_ref { - Some(r) => dst.set(r, Object::Dict(form)), - None => { - let r = dst.add(form); - dst.update_dict(root, |c| c.set(b"AcroForm".to_vec(), Object::Ref(r)))?; - } - } - Ok(()) -} - -/// A new document containing copies of `pages` from `src` (Extract Pages / Split). -/// Document information (title, author…) is carried over, and so is the print standard the -/// source declares (PDF/X: its output intents and identification, #263), and the bookmarks that -/// go to the copied pages. -pub fn extract_pages(src: &Document, pages: &[usize]) -> Result { - let mut out = Document::new_empty(); - // One source: nothing to deduplicate. Its fields keep their names (a source with two - // same-named field roots stays one field, as it was), so Extract and Split never rename. - let imported = import_pages_mapped(&mut out, src, pages, 0, FieldConflicts::Merge)?; - // The bookmarks of these pages come along, mapped to the copies (#4020). - let outline = kept_bookmarks(source_outline(src, imported.page_map)); - if !outline.is_empty() { - let root = crate::outline::container(&mut out, &[])?; - if let Some((first, last, _)) = write_outline_level(&mut out, root, &outline)? { - out.update_dict(root, |d| { - d.set(b"First".to_vec(), Object::Ref(first)); - d.set(b"Last".to_vec(), Object::Ref(last)); - })?; - let top = crate::outline::children(&out, root); - crate::outline::relink_and_recount(&mut out, root, &top)?; - } - } - for key in crate::INFO_KEYS { - if let Some(v) = crate::info(src, key) { - crate::set_info(&mut out, key, &v)?; - } - } - // After the title: the XMP packet repeats it. - crate::pdfx::carry(&mut out, Some(src), &crate::pdfx::PrintStandard::of(src), imported.objects)?; - // The pages share their source's resource dictionary, so the part would otherwise carry every - // XObject the source lists — images and all (#204). Keep only what these pages draw. - crate::prune::prune_unused_xobjects(&mut out)?; - Ok(out) -} - -/// How to split a document. -#[derive(Clone, Debug, PartialEq)] -pub enum SplitBy { - /// Every `n` pages. - PageCount(usize), - /// Before each of these 0-based page indices (e.g. `[3, 7]` → 0–2, 3–6, 7–end). - Before(Vec), -} - -/// The page ranges (0-based, end-exclusive) a split produces. -pub fn split_ranges(page_count: usize, by: &SplitBy) -> Vec> { - let mut cuts: Vec = match by { - SplitBy::PageCount(n) => (1..).map(|i| i * (*n).max(1)).take_while(|c| *c < page_count).collect(), - SplitBy::Before(v) => v.iter().copied().filter(|c| *c > 0 && *c < page_count).collect(), - }; - cuts.sort_unstable(); - cuts.dedup(); - let mut ranges = Vec::new(); - let mut start = 0; - for c in cuts.into_iter().chain(std::iter::once(page_count)) { - if c > start { - ranges.push(start..c); - start = c; - } - } - ranges -} - -/// Split a document into several new documents. -pub fn split(src: &Document, by: &SplitBy) -> Result, OrganizeError> { - let n = crate::page_count(src)?; - split_ranges(n, by).into_iter().map(|r| extract_pages(src, &r.collect::>())).collect() -} - -/// What combining produced, beyond the document: the fields renamed to keep colliding names -/// separate (old fully-qualified name → new one), in order across all sources. -pub struct CombineOutcome { - /// The combined document. - pub doc: Document, - /// Every field renamed while combining. - pub renamed_fields: Vec<(String, String)>, -} - -/// Combine whole documents, in order, into a new document. Each source gets a top-level -/// bookmark (its `title`) pointing to its first page, like Acrobat's Combine Files. -/// The source's own bookmarks are nested (collapsed) under its entry, and its document-level -/// attachments are carried over. When every source declares the same print standard (PDF/X -/// with the same output intent), so does the result. -pub fn combine(sources: &[(&str, &Document)]) -> Result { - combine_with(sources, FieldConflicts::Rename).map(|o| o.doc) -} - -/// [`combine`] reporting what the conflict resolution renamed. -pub fn combine_with(sources: &[(&str, &Document)], conflicts: FieldConflicts) -> Result { - let all: Vec<(&str, &Document, Option<&[usize]>)> = sources.iter().map(|(t, d)| (*t, *d, None)).collect(); - combine_selected_with(&all, conflicts) -} - -/// Combine Files with chosen pages: each source contributes `pages` (0-based, in that order; -/// `None` for all of them). Bookmarks that point at pages left out lose their destination. -pub fn combine_selected(sources: &[(&str, &Document, Option<&[usize]>)]) -> Result { - combine_selected_with(sources, FieldConflicts::Rename).map(|o| o.doc) -} - -/// [`combine_selected`] reporting what the conflict resolution renamed. -pub fn combine_selected_with(sources: &[(&str, &Document, Option<&[usize]>)], conflicts: FieldConflicts) -> Result { - let mut combiner = Combiner::with_field_conflicts(conflicts); - for (title, src, chosen) in sources { - combiner.add(title, src, *chosen)?; - } - combiner.finish_with_renames() -} - -/// Combine Files one source at a time, with the same result as [`combine_selected`]. [`Combiner::add`] -/// copies a source's pages into the result and keeps only what the rest of the combine needs: its -/// bookmarks (read and mapped to the copied pages), its attachments (copied), and its document only -/// when it declares output intents. So each source can be dropped as soon as it is added, and a long -/// list holds one source at a time rather than all of them. -pub struct Combiner { - out: Document, - marks: Vec, - attachments: Vec<(Vec, Object)>, - /// The first source's print standard, and what its pages brought along. - standard: Option, - /// Whether every source so far declares the first source's print standard. - agreed: bool, - /// How colliding field names are resolved as each source is added. - field_conflicts: FieldConflicts, - /// What adding sources renamed (old → new fully-qualified field name), in order. - renamed_fields: Vec<(String, String)>, -} - -/// The first source's print standard (see [`Combiner`]). -struct Standard { - declared: crate::pdfx::PrintStandard, - /// The source objects the copied pages brought along, and their copies. - objects: HashMap, - /// The source itself, kept only for its output intents, which are copied from it. - source: Option, -} - -impl Default for Combiner { - fn default() -> Self { - Self::new() - } -} - -impl Combiner { - /// A combine with no sources yet, renaming colliding field names as sources are added. - pub fn new() -> Self { - Self::with_field_conflicts(FieldConflicts::Rename) - } - - /// [`Combiner::new`] resolving colliding field names as `conflicts` says. - pub fn with_field_conflicts(field_conflicts: FieldConflicts) -> Self { - Self { - out: Document::new_empty(), - marks: Vec::new(), - attachments: Vec::new(), - standard: None, - agreed: true, - field_conflicts, - renamed_fields: Vec::new(), - } - } - - /// What adding sources has renamed so far (old → new fully-qualified field name). - pub fn renamed_fields(&self) -> &[(String, String)] { - &self.renamed_fields - } - - /// Add `src`'s `pages` (0-based, in that order; `None` for all of them) after the pages added so far, - /// under a bookmark titled `title`. `src` need not outlive the call. - pub fn add(&mut self, title: &str, src: &Document, pages: Option<&[usize]>) -> Result<(), OrganizeError> { - let n = crate::page_count(src)?; - let pages: Vec = match pages { - Some(p) => { - if let Some(bad) = p.iter().find(|i| **i >= n) { - return Err(OrganizeError::NoSuchPage(*bad)); - } - p.to_vec() - } - None => (0..n).collect(), - }; - let at = crate::page_count(&self.out)?; - let imported = import_pages_mapped(&mut self.out, src, &pages, at, self.field_conflicts)?; - self.renamed_fields.extend(imported.renamed_fields); - if let Some(first) = imported.pages.first() { - let outline = source_outline(src, imported.page_map); - self.marks.push(Mark { title: title.to_string(), page: *first, outline }); - } - collect_attachments(&mut self.out, src, &mut self.attachments)?; - let declared = crate::pdfx::PrintStandard::of(src); - match self.standard.as_ref().map(|first| first.declared.same_as(&declared)) { - Some(same) => self.agreed &= same, - None => { - let source = declared.declares_output_intents().then(|| src.clone()); - self.standard = Some(Standard { declared, objects: imported.objects, source }); - } - } - Ok(()) - } - - /// The combined document: the pages as added, the print standard of the first source when every - /// source declares the same one, shared resources stored once, then the bookmarks and attachments. - pub fn finish(self) -> Result { - self.finish_with_renames().map(|o| o.doc) - } - - /// [`Combiner::finish`] reporting what the combine renamed. - pub fn finish_with_renames(self) -> Result { - let Combiner { mut out, marks, attachments, standard, agreed, renamed_fields, .. } = self; - if let Some(first) = standard.filter(|_| agreed) { - crate::pdfx::carry(&mut out, first.source.as_ref(), &first.declared, first.objects)?; - } - // Sources often share fonts, images and profiles (or are the same file): store them once. - let all: Vec = out.object_numbers().into_iter().map(|n| ObjRef::new(n, out.generation(n))).collect(); - crate::dedupe::dedupe_resources(&mut out, &all, false); - add_outline(&mut out, &marks)?; - set_attachments(&mut out, attachments)?; - Ok(CombineOutcome { doc: out, renamed_fields }) - } -} - -/// One run of pages for [`combine_grouped`]: the file it comes from (`group`: runs with the same -/// group are one file split around, and give the same document), its title, the document, and -/// its pages (0-based, in that order; `None` for all of them). -pub type Run<'a> = (usize, &'a str, &'a Document, Option<&'a [usize]>); - -/// Combine Files where a file's pages may be split into several runs with other files' pages -/// between them. Each file (group) is copied once, all its pages together, then the pages are put -/// in the runs' order: its links between its own pages, its form fields and its attachments stay -/// whole, and it gets one top-level bookmark (the title of its first run) at its first page, its -/// own bookmarks nested. Otherwise as [`combine_selected`] (print standard, shared resources). -pub fn combine_grouped(runs: &[Run<'_>]) -> Result { - combine_grouped_with(runs, FieldConflicts::Rename).map(|o| o.doc) -} - -/// [`combine_grouped`] reporting what the conflict resolution renamed. -pub fn combine_grouped_with(runs: &[Run<'_>], conflicts: FieldConflicts) -> Result { - // Each file's pages, in the order its runs list them, and each run's place in that list. - struct File<'a> { - title: &'a str, - src: &'a Document, - pages: Vec, - } - let mut files: Vec<(usize, File<'_>)> = Vec::new(); - let mut slices: Vec<(usize, usize, usize)> = Vec::with_capacity(runs.len()); - for (group, title, src, chosen) in runs { - let at = match files.iter().position(|(g, _)| g == group) { - Some(at) => at, - None => { - files.push((*group, File { title, src, pages: Vec::new() })); - files.len() - 1 - } - }; - let Some((_, file)) = files.get_mut(at) else { continue }; - let n = crate::page_count(file.src)?; - let pages: Vec = match chosen { - Some(p) => { - if let Some(bad) = p.iter().find(|i| **i >= n) { - return Err(OrganizeError::NoSuchPage(*bad)); - } - p.to_vec() - } - None => (0..n).collect(), - }; - slices.push((at, file.pages.len(), pages.len())); - file.pages.extend(pages); - } - let mut out = Document::new_empty(); - let mut marks = Vec::new(); - let mut attachments = Vec::new(); - let mut copies: Vec> = Vec::with_capacity(files.len()); - let mut renamed_fields = Vec::new(); - let mut standard: Option<(crate::pdfx::PrintStandard, &Document, HashMap)> = None; - let mut agreed = true; - for (_, file) in &files { - let at = crate::page_count(&out)?; - let imported = import_pages_mapped(&mut out, file.src, &file.pages, at, conflicts)?; - copies.push(imported.pages.clone()); - renamed_fields.extend(imported.renamed_fields); - if let Some(first) = imported.pages.first() { - marks.push(Mark { title: file.title.to_string(), page: *first, outline: source_outline(file.src, imported.page_map) }); - } - collect_attachments(&mut out, file.src, &mut attachments)?; - let declared = crate::pdfx::PrintStandard::of(file.src); - match &standard { - None => standard = Some((declared, file.src, imported.objects)), - Some((first, _, _)) => agreed &= first.same_as(&declared), - } - } - // The pages in the runs' order. A file's first run comes before its others, and its pages - // were copied in run order, so its bookmark (its first copied page) is its first page shown. - let mut order: Vec<(ObjRef, Dict)> = Vec::new(); - for (file, start, len) in slices { - let run = copies.get(file).and_then(|c| c.get(start..start.saturating_add(len))).unwrap_or_default(); - order.extend(run.iter().map(|r| (*r, Dict::new()))); - } - rebuild(&mut out, &order)?; - if let Some((declared, src, objects)) = standard.filter(|_| agreed) { - crate::pdfx::carry(&mut out, Some(src), &declared, objects)?; - } - let all: Vec = out.object_numbers().into_iter().map(|n| ObjRef::new(n, out.generation(n))).collect(); - crate::dedupe::dedupe_resources(&mut out, &all, false); - add_outline(&mut out, &marks)?; - set_attachments(&mut out, attachments)?; - Ok(CombineOutcome { doc: out, renamed_fields }) -} - -/// Copy a source's document-level attachments (`/Names /EmbeddedFiles`) into `dst`. -fn collect_attachments(dst: &mut Document, src: &Document, out: &mut Vec<(Vec, Object)>) -> Result<(), OrganizeError> { - let Some(tree) = src - .root() - .and_then(|r| src.get(r).as_dict().cloned()) - .and_then(|c| c.get(b"Names").map(|n| src.resolve(n))) - .and_then(|n| n.as_dict().and_then(|d| d.get(b"EmbeddedFiles").cloned())) - else { - return Ok(()); - }; - let mut entries = Vec::new(); - name_tree_entries(src, &tree, 0, &mut HashSet::new(), &mut entries); - let mut copier = Copier::new(src, HashMap::new(), HashMap::new()); - for (k, v) in entries { - let v = copier.copy_value(dst, &v, None)?; - out.push((k, v)); - } - Ok(()) -} - -/// Write attachments as a flat, sorted `/EmbeddedFiles` name tree; duplicate names get " (2)"… -pub(crate) fn set_attachments(dst: &mut Document, mut entries: Vec<(Vec, Object)>) -> Result<(), OrganizeError> { - if entries.is_empty() { - return Ok(()); - } - // Names are compared and numbered as text: a key written as UTF-16 (a name with an accent - // or Chinese letters) must not get ASCII bytes appended to it. - let mut seen = std::collections::HashSet::new(); - for (k, _) in entries.iter_mut() { - let base = pdfcraft_cos::PdfString::literal(k.clone()).to_text(); - let mut text = base.clone(); - let mut n = 2; - while !seen.insert(text.clone()) { - text = format!("{base} ({n})"); - n += 1; - } - if text != base { - *k = pdfcraft_cos::PdfString::text(&text).bytes; - } - } - entries.sort_by(|a, b| a.0.cmp(&b.0)); - let mut names = Vec::new(); - for (k, v) in entries { - names.push(Object::String(pdfcraft_cos::PdfString::literal(k))); - names.push(v); - } - let mut tree = Dict::new(); - tree.set(b"Names".to_vec(), Object::Array(names)); - let tree = dst.add(tree); - let root = dst.root().ok_or(OrganizeError::NoPageTree)?; - // `/Names` may be an indirect dictionary (Acrobat writes it so): update it where it is, so - // its other trees (/Dests, /JavaScript…) stay. - if let Some(Object::Ref(names)) = dst.get(root).as_dict().and_then(|c| c.get(b"Names").cloned()) - && dst.get(names).as_dict().is_some() - { - dst.update_dict(names, |n| n.set(b"EmbeddedFiles".to_vec(), Object::Ref(tree)))?; - return Ok(()); - } - dst.update_dict(root, |c| { - let mut n = match c.get(b"Names") { - Some(Object::Dict(d)) => d.clone(), - _ => Dict::new(), - }; - n.set(b"EmbeddedFiles".to_vec(), Object::Ref(tree)); - c.set(b"Names".to_vec(), Object::Dict(n)); - })?; - Ok(()) -} - -/// A source's entry in the combined outline (see [`add_outline`]): its title, its first page in the -/// result, and its own bookmarks, read from the source when it was added. -struct Mark { - title: String, - page: ObjRef, - outline: Vec, -} - -/// One of a source's bookmarks, read and ready to write: its title, colour and flags, its destination -/// in the result (`None` when the page it points at was not copied), whether it is open, and its own -/// bookmarks. -struct OutlineNode { - title: Option, - colour: Option, - flags: Option, - dest: Option, - open: bool, - children: Vec, -} - -/// The bookmarks of `src`, with destinations mapped through `pages` (source page to copied page). They -/// are read while `src` is at hand, so the source itself need not be kept. -fn source_outline(src: &Document, pages: HashMap) -> Vec { - let first = src - .root() - .and_then(|r| src.get(r).as_dict().cloned()) - .and_then(|c| c.get(b"Outlines").map(|o| src.resolve(o))) - .and_then(|o| o.as_dict().and_then(|d| d.reference(b"First"))); - let mapper = Copier::new(src, HashMap::new(), pages); - let mut budget = 10_000usize; - outline_level(&mapper, first, 0, &mut budget) -} - -/// `nodes` without the bookmarks whose page was not copied (no destination left), unless a bookmark -/// under them stays. [`outline_level`] already bounds the depth. -fn kept_bookmarks(nodes: Vec) -> Vec { - nodes - .into_iter() - .filter_map(|mut n| { - n.children = kept_bookmarks(n.children); - (n.dest.is_some() || !n.children.is_empty()).then_some(n) - }) - .collect() -} - -/// One level of bookmarks from `first` (its siblings), each with its children. Cycle- and size-safe: -/// at most `budget` bookmarks are read in all, and nesting stops at 32 levels. -fn outline_level(m: &Copier<'_>, first: Option, depth: u8, budget: &mut usize) -> Vec { - if depth > 32 { - return Vec::new(); - } - let mut seen = std::collections::HashSet::new(); - let mut made = Vec::new(); - let mut cur = first; - while let Some(r) = cur { - if !seen.insert(r) || *budget == 0 { - break; - } - *budget -= 1; - let Some(item) = m.src.get(r).as_dict().cloned() else { break }; - cur = item.reference(b"Next"); - let title = item.get(b"Title").map(|t| m.src.resolve(t).as_ref().clone()); - let colour = item.get(b"C").map(|v| m.src.resolve(v).as_ref().clone()); - let flags = item.get(b"F").map(|v| m.src.resolve(v).as_ref().clone()); - let target = item.get(b"Dest").cloned().or_else(|| { - let a = m.src.resolve(item.get(b"A")?); - let a = a.as_dict()?; - (a.name(b"S") == Some(b"GoTo")).then(|| a.get(b"D").cloned()).flatten() - }); - let dest = target.and_then(|t| m.map_dest(&t)); - let open = item.int(b"Count").is_some_and(|c| c > 0); - let children = outline_level(m, item.reference(b"First"), depth + 1, budget); - made.push(OutlineNode { title, colour, flags, dest, open, children }); - } - made -} - -/// Write `nodes` as one level under `parent`: each bookmark is created, then its children, depth first -/// and in order, so the object numbers come out as they always did. Returns the level's first and last -/// item and how many items it has (`None` when it is empty). -fn write_outline_level(dst: &mut Document, parent: ObjRef, nodes: &[OutlineNode]) -> Result, OrganizeError> { - let mut made: Vec = Vec::new(); - for node in nodes { - let new = dst.add(Object::Null); - let mut d = Dict::new(); - if let Some(t) = &node.title { - d.set(b"Title".to_vec(), t.clone()); - } - if let Some(c) = &node.colour { - d.set(b"C".to_vec(), c.clone()); - } - if let Some(f) = &node.flags { - d.set(b"F".to_vec(), f.clone()); - } - if let Some(dest) = &node.dest { - d.set(b"Dest".to_vec(), dest.clone()); - } - d.set(b"Parent".to_vec(), Object::Ref(parent)); - if let Some(prev) = made.last() { - d.set(b"Prev".to_vec(), Object::Ref(*prev)); - dst.update_dict(*prev, |p| p.set(b"Next".to_vec(), Object::Ref(new)))?; - } - if let Some((f, l, n)) = write_outline_level(dst, new, &node.children)? { - d.set(b"First".to_vec(), Object::Ref(f)); - d.set(b"Last".to_vec(), Object::Ref(l)); - // Keep the source's open/closed state. - d.set(b"Count".to_vec(), Object::Int(if node.open { n as i64 } else { -(n as i64) })); - } - dst.set(new, Object::Dict(d)); - made.push(new); - } - Ok(match (made.first(), made.last()) { - (Some(first), Some(last)) => Some((*first, *last, made.len())), - _ => None, - }) -} - -/// Replace the outline with one bookmark per source (title → its first page), each holding a collapsed -/// copy of that source's own bookmarks with destinations mapped to the copied pages. -fn add_outline(doc: &mut Document, marks: &[Mark]) -> Result<(), OrganizeError> { - if marks.is_empty() { - return Ok(()); - } - let outlines = doc.add(Object::Null); - let items: Vec = marks.iter().map(|_| doc.add(Object::Null)).collect(); - let (Some(&first_item), Some(&last_item)) = (items.first(), items.last()) else { return Ok(()) }; - for (i, (mark, r)) in marks.iter().zip(&items).enumerate() { - let mut d = Dict::new(); - d.set(b"Title".to_vec(), Object::String(pdfcraft_cos::PdfString::text(&mark.title))); - d.set(b"Parent".to_vec(), Object::Ref(outlines)); - d.set(b"Dest".to_vec(), Object::Array(vec![Object::Ref(mark.page), Object::name("Fit")])); - if i > 0 { - d.set(b"Prev".to_vec(), Object::Ref(items[i - 1])); - } - if let Some(next) = items.get(i + 1) { - d.set(b"Next".to_vec(), Object::Ref(*next)); - } - if let Some((f, l, n)) = write_outline_level(doc, *r, &mark.outline)? { - d.set(b"First".to_vec(), Object::Ref(f)); - d.set(b"Last".to_vec(), Object::Ref(l)); - d.set(b"Count".to_vec(), Object::Int(-(n as i64))); // collapsed - } - doc.set(*r, Object::Dict(d)); - } - let mut o = Dict::new(); - o.set(b"Type".to_vec(), Object::name("Outlines")); - o.set(b"First".to_vec(), Object::Ref(first_item)); - o.set(b"Last".to_vec(), Object::Ref(last_item)); - o.set(b"Count".to_vec(), Object::Int(items.len() as i64)); - doc.set(outlines, Object::Dict(o)); - let root = doc.root().ok_or(OrganizeError::NoPageTree)?; - doc.update_dict(root, |c| { - c.set(b"Outlines".to_vec(), Object::Ref(outlines)); - c.set(b"PageMode".to_vec(), Object::name("UseOutlines")); - })?; - Ok(()) -} - /// Page `page` of `src` as a form XObject in `dst` (its content and resources, no annotations), /// for backgrounds and watermarks taken from a PDF. The form's `/BBox` is the page's crop box /// and its `/Matrix` undoes the page rotation, so it draws upright as displayed. Returns the diff --git a/crates/organize/src/import/carry.rs b/crates/organize/src/import/carry.rs new file mode 100644 index 0000000000..15a412e231 --- /dev/null +++ b/crates/organize/src/import/carry.rs @@ -0,0 +1,233 @@ +use super::copy::Copier; +use super::{Dict, Document, HashMap, HashSet, ObjRef, Object, OrganizeError, name_tree_entries}; + +/// Copy a source's document-level attachments (`/Names /EmbeddedFiles`) into `dst`. +pub(super) fn collect_attachments(dst: &mut Document, src: &Document, out: &mut Vec<(Vec, Object)>) -> Result<(), OrganizeError> { + let Some(tree) = src + .root() + .and_then(|r| src.get(r).as_dict().cloned()) + .and_then(|c| c.get(b"Names").map(|n| src.resolve(n))) + .and_then(|n| n.as_dict().and_then(|d| d.get(b"EmbeddedFiles").cloned())) + else { + return Ok(()); + }; + let mut entries = Vec::new(); + name_tree_entries(src, &tree, 0, &mut HashSet::new(), &mut entries); + let mut copier = Copier::new(src, HashMap::new(), HashMap::new()); + for (k, v) in entries { + let v = copier.copy_value(dst, &v, None)?; + out.push((k, v)); + } + Ok(()) +} + +/// Write attachments as a flat, sorted `/EmbeddedFiles` name tree; duplicate names get " (2)"… +pub(crate) fn set_attachments(dst: &mut Document, mut entries: Vec<(Vec, Object)>) -> Result<(), OrganizeError> { + if entries.is_empty() { + return Ok(()); + } + // Names are compared and numbered as text: a key written as UTF-16 (a name with an accent + // or Chinese letters) must not get ASCII bytes appended to it. + let mut seen = std::collections::HashSet::new(); + for (k, _) in entries.iter_mut() { + let base = pdfcraft_cos::PdfString::literal(k.clone()).to_text(); + let mut text = base.clone(); + let mut n = 2; + while !seen.insert(text.clone()) { + text = format!("{base} ({n})"); + n += 1; + } + if text != base { + *k = pdfcraft_cos::PdfString::text(&text).bytes; + } + } + entries.sort_by(|a, b| a.0.cmp(&b.0)); + let mut names = Vec::new(); + for (k, v) in entries { + names.push(Object::String(pdfcraft_cos::PdfString::literal(k))); + names.push(v); + } + let mut tree = Dict::new(); + tree.set(b"Names".to_vec(), Object::Array(names)); + let tree = dst.add(tree); + let root = dst.root().ok_or(OrganizeError::NoPageTree)?; + // `/Names` may be an indirect dictionary (Acrobat writes it so): update it where it is, so + // its other trees (/Dests, /JavaScript…) stay. + if let Some(Object::Ref(names)) = dst.get(root).as_dict().and_then(|c| c.get(b"Names").cloned()) + && dst.get(names).as_dict().is_some() + { + dst.update_dict(names, |n| n.set(b"EmbeddedFiles".to_vec(), Object::Ref(tree)))?; + return Ok(()); + } + dst.update_dict(root, |c| { + let mut n = match c.get(b"Names") { + Some(Object::Dict(d)) => d.clone(), + _ => Dict::new(), + }; + n.set(b"EmbeddedFiles".to_vec(), Object::Ref(tree)); + c.set(b"Names".to_vec(), Object::Dict(n)); + })?; + Ok(()) +} + +/// A source's entry in the combined outline (see [`add_outline`]): its title, its first page in the +/// result, and its own bookmarks, read from the source when it was added. +pub(super) struct Mark { + pub(super) title: String, + pub(super) page: ObjRef, + pub(super) outline: Vec, +} + +/// One of a source's bookmarks, read and ready to write: its title, colour and flags, its destination +/// in the result (`None` when the page it points at was not copied), whether it is open, and its own +/// bookmarks. +pub(super) struct OutlineNode { + title: Option, + colour: Option, + flags: Option, + dest: Option, + open: bool, + children: Vec, +} + +/// The bookmarks of `src`, with destinations mapped through `pages` (source page to copied page). They +/// are read while `src` is at hand, so the source itself need not be kept. +pub(super) fn source_outline(src: &Document, pages: HashMap) -> Vec { + let first = src + .root() + .and_then(|r| src.get(r).as_dict().cloned()) + .and_then(|c| c.get(b"Outlines").map(|o| src.resolve(o))) + .and_then(|o| o.as_dict().and_then(|d| d.reference(b"First"))); + let mapper = Copier::new(src, HashMap::new(), pages); + let mut budget = 10_000usize; + outline_level(&mapper, first, 0, &mut budget) +} + +/// `nodes` without the bookmarks whose page was not copied (no destination left), unless a bookmark +/// under them stays. [`outline_level`] already bounds the depth. +pub(super) fn kept_bookmarks(nodes: Vec) -> Vec { + nodes + .into_iter() + .filter_map(|mut n| { + n.children = kept_bookmarks(n.children); + (n.dest.is_some() || !n.children.is_empty()).then_some(n) + }) + .collect() +} + +/// One level of bookmarks from `first` (its siblings), each with its children. Cycle- and size-safe: +/// at most `budget` bookmarks are read in all, and nesting stops at 32 levels. +fn outline_level(m: &Copier<'_>, first: Option, depth: u8, budget: &mut usize) -> Vec { + if depth > 32 { + return Vec::new(); + } + let mut seen = std::collections::HashSet::new(); + let mut made = Vec::new(); + let mut cur = first; + while let Some(r) = cur { + if !seen.insert(r) || *budget == 0 { + break; + } + *budget -= 1; + let Some(item) = m.src.get(r).as_dict().cloned() else { break }; + cur = item.reference(b"Next"); + let title = item.get(b"Title").map(|t| m.src.resolve(t).as_ref().clone()); + let colour = item.get(b"C").map(|v| m.src.resolve(v).as_ref().clone()); + let flags = item.get(b"F").map(|v| m.src.resolve(v).as_ref().clone()); + let target = item.get(b"Dest").cloned().or_else(|| { + let a = m.src.resolve(item.get(b"A")?); + let a = a.as_dict()?; + (a.name(b"S") == Some(b"GoTo")).then(|| a.get(b"D").cloned()).flatten() + }); + let dest = target.and_then(|t| m.map_dest(&t)); + let open = item.int(b"Count").is_some_and(|c| c > 0); + let children = outline_level(m, item.reference(b"First"), depth + 1, budget); + made.push(OutlineNode { title, colour, flags, dest, open, children }); + } + made +} + +/// Write `nodes` as one level under `parent`: each bookmark is created, then its children, depth first +/// and in order, so the object numbers come out as they always did. Returns the level's first and last +/// item and how many items it has (`None` when it is empty). +pub(super) fn write_outline_level( + dst: &mut Document, + parent: ObjRef, + nodes: &[OutlineNode], +) -> Result, OrganizeError> { + let mut made: Vec = Vec::new(); + for node in nodes { + let new = dst.add(Object::Null); + let mut d = Dict::new(); + if let Some(t) = &node.title { + d.set(b"Title".to_vec(), t.clone()); + } + if let Some(c) = &node.colour { + d.set(b"C".to_vec(), c.clone()); + } + if let Some(f) = &node.flags { + d.set(b"F".to_vec(), f.clone()); + } + if let Some(dest) = &node.dest { + d.set(b"Dest".to_vec(), dest.clone()); + } + d.set(b"Parent".to_vec(), Object::Ref(parent)); + if let Some(prev) = made.last() { + d.set(b"Prev".to_vec(), Object::Ref(*prev)); + dst.update_dict(*prev, |p| p.set(b"Next".to_vec(), Object::Ref(new)))?; + } + if let Some((f, l, n)) = write_outline_level(dst, new, &node.children)? { + d.set(b"First".to_vec(), Object::Ref(f)); + d.set(b"Last".to_vec(), Object::Ref(l)); + // Keep the source's open/closed state. + d.set(b"Count".to_vec(), Object::Int(if node.open { n as i64 } else { -(n as i64) })); + } + dst.set(new, Object::Dict(d)); + made.push(new); + } + Ok(match (made.first(), made.last()) { + (Some(first), Some(last)) => Some((*first, *last, made.len())), + _ => None, + }) +} + +/// Replace the outline with one bookmark per source (title → its first page), each holding a collapsed +/// copy of that source's own bookmarks with destinations mapped to the copied pages. +pub(super) fn add_outline(doc: &mut Document, marks: &[Mark]) -> Result<(), OrganizeError> { + if marks.is_empty() { + return Ok(()); + } + let outlines = doc.add(Object::Null); + let items: Vec = marks.iter().map(|_| doc.add(Object::Null)).collect(); + let (Some(&first_item), Some(&last_item)) = (items.first(), items.last()) else { return Ok(()) }; + for (i, (mark, r)) in marks.iter().zip(&items).enumerate() { + let mut d = Dict::new(); + d.set(b"Title".to_vec(), Object::String(pdfcraft_cos::PdfString::text(&mark.title))); + d.set(b"Parent".to_vec(), Object::Ref(outlines)); + d.set(b"Dest".to_vec(), Object::Array(vec![Object::Ref(mark.page), Object::name("Fit")])); + if i > 0 { + d.set(b"Prev".to_vec(), Object::Ref(items[i - 1])); + } + if let Some(next) = items.get(i + 1) { + d.set(b"Next".to_vec(), Object::Ref(*next)); + } + if let Some((f, l, n)) = write_outline_level(doc, *r, &mark.outline)? { + d.set(b"First".to_vec(), Object::Ref(f)); + d.set(b"Last".to_vec(), Object::Ref(l)); + d.set(b"Count".to_vec(), Object::Int(-(n as i64))); // collapsed + } + doc.set(*r, Object::Dict(d)); + } + let mut o = Dict::new(); + o.set(b"Type".to_vec(), Object::name("Outlines")); + o.set(b"First".to_vec(), Object::Ref(first_item)); + o.set(b"Last".to_vec(), Object::Ref(last_item)); + o.set(b"Count".to_vec(), Object::Int(items.len() as i64)); + doc.set(outlines, Object::Dict(o)); + let root = doc.root().ok_or(OrganizeError::NoPageTree)?; + doc.update_dict(root, |c| { + c.set(b"Outlines".to_vec(), Object::Ref(outlines)); + c.set(b"PageMode".to_vec(), Object::name("UseOutlines")); + })?; + Ok(()) +} diff --git a/crates/organize/src/import/combine.rs b/crates/organize/src/import/combine.rs new file mode 100644 index 0000000000..06972ae101 --- /dev/null +++ b/crates/organize/src/import/combine.rs @@ -0,0 +1,307 @@ +use super::carry::{Mark, add_outline, collect_attachments, kept_bookmarks, set_attachments, source_outline, write_outline_level}; +use super::{Dict, Document, FieldConflicts, HashMap, ObjRef, Object, OrganizeError, import_pages_mapped, rebuild}; + +/// A new document containing copies of `pages` from `src` (Extract Pages / Split). +/// Document information (title, author…) is carried over, and so is the print standard the +/// source declares (PDF/X: its output intents and identification, #263), and the bookmarks that +/// go to the copied pages. +pub fn extract_pages(src: &Document, pages: &[usize]) -> Result { + let mut out = Document::new_empty(); + // One source: nothing to deduplicate. Its fields keep their names (a source with two + // same-named field roots stays one field, as it was), so Extract and Split never rename. + let imported = import_pages_mapped(&mut out, src, pages, 0, FieldConflicts::Merge)?; + // The bookmarks of these pages come along, mapped to the copies (#4020). + let outline = kept_bookmarks(source_outline(src, imported.page_map)); + if !outline.is_empty() { + let root = crate::outline::container(&mut out, &[])?; + if let Some((first, last, _)) = write_outline_level(&mut out, root, &outline)? { + out.update_dict(root, |d| { + d.set(b"First".to_vec(), Object::Ref(first)); + d.set(b"Last".to_vec(), Object::Ref(last)); + })?; + let top = crate::outline::children(&out, root); + crate::outline::relink_and_recount(&mut out, root, &top)?; + } + } + for key in crate::INFO_KEYS { + if let Some(v) = crate::info(src, key) { + crate::set_info(&mut out, key, &v)?; + } + } + // After the title: the XMP packet repeats it. + crate::pdfx::carry(&mut out, Some(src), &crate::pdfx::PrintStandard::of(src), imported.objects)?; + // The pages share their source's resource dictionary, so the part would otherwise carry every + // XObject the source lists — images and all (#204). Keep only what these pages draw. + crate::prune::prune_unused_xobjects(&mut out)?; + Ok(out) +} + +/// How to split a document. +#[derive(Clone, Debug, PartialEq)] +pub enum SplitBy { + /// Every `n` pages. + PageCount(usize), + /// Before each of these 0-based page indices (e.g. `[3, 7]` → 0–2, 3–6, 7–end). + Before(Vec), +} + +/// The page ranges (0-based, end-exclusive) a split produces. +pub fn split_ranges(page_count: usize, by: &SplitBy) -> Vec> { + let mut cuts: Vec = match by { + SplitBy::PageCount(n) => (1..).map(|i| i * (*n).max(1)).take_while(|c| *c < page_count).collect(), + SplitBy::Before(v) => v.iter().copied().filter(|c| *c > 0 && *c < page_count).collect(), + }; + cuts.sort_unstable(); + cuts.dedup(); + let mut ranges = Vec::new(); + let mut start = 0; + for c in cuts.into_iter().chain(std::iter::once(page_count)) { + if c > start { + ranges.push(start..c); + start = c; + } + } + ranges +} + +/// Split a document into several new documents. +pub fn split(src: &Document, by: &SplitBy) -> Result, OrganizeError> { + let n = crate::page_count(src)?; + split_ranges(n, by).into_iter().map(|r| extract_pages(src, &r.collect::>())).collect() +} + +/// What combining produced, beyond the document: the fields renamed to keep colliding names +/// separate (old fully-qualified name → new one), in order across all sources. +pub struct CombineOutcome { + /// The combined document. + pub doc: Document, + /// Every field renamed while combining. + pub renamed_fields: Vec<(String, String)>, +} + +/// Combine whole documents, in order, into a new document. Each source gets a top-level +/// bookmark (its `title`) pointing to its first page, like Acrobat's Combine Files. +/// The source's own bookmarks are nested (collapsed) under its entry, and its document-level +/// attachments are carried over. When every source declares the same print standard (PDF/X +/// with the same output intent), so does the result. +pub fn combine(sources: &[(&str, &Document)]) -> Result { + combine_with(sources, FieldConflicts::Rename).map(|o| o.doc) +} + +/// [`combine`] reporting what the conflict resolution renamed. +pub fn combine_with(sources: &[(&str, &Document)], conflicts: FieldConflicts) -> Result { + let all: Vec<(&str, &Document, Option<&[usize]>)> = sources.iter().map(|(t, d)| (*t, *d, None)).collect(); + combine_selected_with(&all, conflicts) +} + +/// Combine Files with chosen pages: each source contributes `pages` (0-based, in that order; +/// `None` for all of them). Bookmarks that point at pages left out lose their destination. +pub fn combine_selected(sources: &[(&str, &Document, Option<&[usize]>)]) -> Result { + combine_selected_with(sources, FieldConflicts::Rename).map(|o| o.doc) +} + +/// [`combine_selected`] reporting what the conflict resolution renamed. +pub fn combine_selected_with(sources: &[(&str, &Document, Option<&[usize]>)], conflicts: FieldConflicts) -> Result { + let mut combiner = Combiner::with_field_conflicts(conflicts); + for (title, src, chosen) in sources { + combiner.add(title, src, *chosen)?; + } + combiner.finish_with_renames() +} + +/// Combine Files one source at a time, with the same result as [`combine_selected`]. [`Combiner::add`] +/// copies a source's pages into the result and keeps only what the rest of the combine needs: its +/// bookmarks (read and mapped to the copied pages), its attachments (copied), and its document only +/// when it declares output intents. So each source can be dropped as soon as it is added, and a long +/// list holds one source at a time rather than all of them. +pub struct Combiner { + out: Document, + marks: Vec, + attachments: Vec<(Vec, Object)>, + /// The first source's print standard, and what its pages brought along. + standard: Option, + /// Whether every source so far declares the first source's print standard. + agreed: bool, + /// How colliding field names are resolved as each source is added. + field_conflicts: FieldConflicts, + /// What adding sources renamed (old → new fully-qualified field name), in order. + renamed_fields: Vec<(String, String)>, +} + +/// The first source's print standard (see [`Combiner`]). +struct Standard { + declared: crate::pdfx::PrintStandard, + /// The source objects the copied pages brought along, and their copies. + objects: HashMap, + /// The source itself, kept only for its output intents, which are copied from it. + source: Option, +} + +impl Default for Combiner { + fn default() -> Self { + Self::new() + } +} + +impl Combiner { + /// A combine with no sources yet, renaming colliding field names as sources are added. + pub fn new() -> Self { + Self::with_field_conflicts(FieldConflicts::Rename) + } + + /// [`Combiner::new`] resolving colliding field names as `conflicts` says. + pub fn with_field_conflicts(field_conflicts: FieldConflicts) -> Self { + Self { + out: Document::new_empty(), + marks: Vec::new(), + attachments: Vec::new(), + standard: None, + agreed: true, + field_conflicts, + renamed_fields: Vec::new(), + } + } + + /// What adding sources has renamed so far (old → new fully-qualified field name). + pub fn renamed_fields(&self) -> &[(String, String)] { + &self.renamed_fields + } + + /// Add `src`'s `pages` (0-based, in that order; `None` for all of them) after the pages added so far, + /// under a bookmark titled `title`. `src` need not outlive the call. + pub fn add(&mut self, title: &str, src: &Document, pages: Option<&[usize]>) -> Result<(), OrganizeError> { + let n = crate::page_count(src)?; + let pages: Vec = match pages { + Some(p) => { + if let Some(bad) = p.iter().find(|i| **i >= n) { + return Err(OrganizeError::NoSuchPage(*bad)); + } + p.to_vec() + } + None => (0..n).collect(), + }; + let at = crate::page_count(&self.out)?; + let imported = import_pages_mapped(&mut self.out, src, &pages, at, self.field_conflicts)?; + self.renamed_fields.extend(imported.renamed_fields); + if let Some(first) = imported.pages.first() { + let outline = source_outline(src, imported.page_map); + self.marks.push(Mark { title: title.to_string(), page: *first, outline }); + } + collect_attachments(&mut self.out, src, &mut self.attachments)?; + let declared = crate::pdfx::PrintStandard::of(src); + match self.standard.as_ref().map(|first| first.declared.same_as(&declared)) { + Some(same) => self.agreed &= same, + None => { + let source = declared.declares_output_intents().then(|| src.clone()); + self.standard = Some(Standard { declared, objects: imported.objects, source }); + } + } + Ok(()) + } + + /// The combined document: the pages as added, the print standard of the first source when every + /// source declares the same one, shared resources stored once, then the bookmarks and attachments. + pub fn finish(self) -> Result { + self.finish_with_renames().map(|o| o.doc) + } + + /// [`Combiner::finish`] reporting what the combine renamed. + pub fn finish_with_renames(self) -> Result { + let Combiner { mut out, marks, attachments, standard, agreed, renamed_fields, .. } = self; + if let Some(first) = standard.filter(|_| agreed) { + crate::pdfx::carry(&mut out, first.source.as_ref(), &first.declared, first.objects)?; + } + // Sources often share fonts, images and profiles (or are the same file): store them once. + let all: Vec = out.object_numbers().into_iter().map(|n| ObjRef::new(n, out.generation(n))).collect(); + crate::dedupe::dedupe_resources(&mut out, &all, false); + add_outline(&mut out, &marks)?; + set_attachments(&mut out, attachments)?; + Ok(CombineOutcome { doc: out, renamed_fields }) + } +} + +/// One run of pages for [`combine_grouped`]: the file it comes from (`group`: runs with the same +/// group are one file split around, and give the same document), its title, the document, and +/// its pages (0-based, in that order; `None` for all of them). +pub type Run<'a> = (usize, &'a str, &'a Document, Option<&'a [usize]>); + +/// Combine Files where a file's pages may be split into several runs with other files' pages +/// between them. Each file (group) is copied once, all its pages together, then the pages are put +/// in the runs' order: its links between its own pages, its form fields and its attachments stay +/// whole, and it gets one top-level bookmark (the title of its first run) at its first page, its +/// own bookmarks nested. Otherwise as [`combine_selected`] (print standard, shared resources). +pub fn combine_grouped(runs: &[Run<'_>]) -> Result { + combine_grouped_with(runs, FieldConflicts::Rename).map(|o| o.doc) +} + +/// [`combine_grouped`] reporting what the conflict resolution renamed. +pub fn combine_grouped_with(runs: &[Run<'_>], conflicts: FieldConflicts) -> Result { + // Each file's pages, in the order its runs list them, and each run's place in that list. + struct File<'a> { + title: &'a str, + src: &'a Document, + pages: Vec, + } + let mut files: Vec<(usize, File<'_>)> = Vec::new(); + let mut slices: Vec<(usize, usize, usize)> = Vec::with_capacity(runs.len()); + for (group, title, src, chosen) in runs { + let at = match files.iter().position(|(g, _)| g == group) { + Some(at) => at, + None => { + files.push((*group, File { title, src, pages: Vec::new() })); + files.len() - 1 + } + }; + let Some((_, file)) = files.get_mut(at) else { continue }; + let n = crate::page_count(file.src)?; + let pages: Vec = match chosen { + Some(p) => { + if let Some(bad) = p.iter().find(|i| **i >= n) { + return Err(OrganizeError::NoSuchPage(*bad)); + } + p.to_vec() + } + None => (0..n).collect(), + }; + slices.push((at, file.pages.len(), pages.len())); + file.pages.extend(pages); + } + let mut out = Document::new_empty(); + let mut marks = Vec::new(); + let mut attachments = Vec::new(); + let mut copies: Vec> = Vec::with_capacity(files.len()); + let mut renamed_fields = Vec::new(); + let mut standard: Option<(crate::pdfx::PrintStandard, &Document, HashMap)> = None; + let mut agreed = true; + for (_, file) in &files { + let at = crate::page_count(&out)?; + let imported = import_pages_mapped(&mut out, file.src, &file.pages, at, conflicts)?; + copies.push(imported.pages.clone()); + renamed_fields.extend(imported.renamed_fields); + if let Some(first) = imported.pages.first() { + marks.push(Mark { title: file.title.to_string(), page: *first, outline: source_outline(file.src, imported.page_map) }); + } + collect_attachments(&mut out, file.src, &mut attachments)?; + let declared = crate::pdfx::PrintStandard::of(file.src); + match &standard { + None => standard = Some((declared, file.src, imported.objects)), + Some((first, _, _)) => agreed &= first.same_as(&declared), + } + } + // The pages in the runs' order. A file's first run comes before its others, and its pages + // were copied in run order, so its bookmark (its first copied page) is its first page shown. + let mut order: Vec<(ObjRef, Dict)> = Vec::new(); + for (file, start, len) in slices { + let run = copies.get(file).and_then(|c| c.get(start..start.saturating_add(len))).unwrap_or_default(); + order.extend(run.iter().map(|r| (*r, Dict::new()))); + } + rebuild(&mut out, &order)?; + if let Some((declared, src, objects)) = standard.filter(|_| agreed) { + crate::pdfx::carry(&mut out, Some(src), &declared, objects)?; + } + let all: Vec = out.object_numbers().into_iter().map(|n| ObjRef::new(n, out.generation(n))).collect(); + crate::dedupe::dedupe_resources(&mut out, &all, false); + add_outline(&mut out, &marks)?; + set_attachments(&mut out, attachments)?; + Ok(CombineOutcome { doc: out, renamed_fields }) +} diff --git a/crates/organize/src/import/copy.rs b/crates/organize/src/import/copy.rs new file mode 100644 index 0000000000..5e4be54150 --- /dev/null +++ b/crates/organize/src/import/copy.rs @@ -0,0 +1,220 @@ +use super::{Dict, Document, HashMap, ObjRef, Object, OrganizeError, named_dest}; + +/// Keys that must not be followed when copying: they point back up or across the source +/// document, and following them would drag in unrelated objects. +pub(super) const SKIP_ON_PAGE: &[&[u8]] = &[b"Parent", b"B", b"StructParents", b"Thumb", b"PieceInfo"]; + +const SKIP_ON_ANNOT: &[&[u8]] = &[b"StructParent", b"P"]; + +/// How deep a copy follows references and nested arrays and dictionaries, counted together. +/// Real documents stay well under this (a page's resources, fonts and descriptors, nested +/// form XObjects and field trees are a few levels each); a chain of thousands of indirect +/// objects, each shallow enough for the parser, would otherwise exhaust the stack (#1800). +const MAX_COPY_DEPTH: usize = 256; + +/// Keys that stay on the widget when a combined field/widget is split (#1810): the annotation's +/// appearance, placement and page. Field state (name, value, flags, default appearance) moves to +/// the new parent field, where the widgets inherit it. +pub(super) const WIDGET_KEYS: &[&[u8]] = &[ + b"Type", + b"Subtype", + b"Rect", + b"P", + b"AP", + b"AS", + b"MK", + b"BS", + b"Border", + b"C", + b"CA", + b"F", + b"H", + b"R", + b"NM", + b"M", + b"StructParent", + b"StructParents", + b"OC", + b"Tabs", + b"A", + b"Contents", + b"BM", + b"Lang", + b"AF", +]; + +pub(crate) struct Copier<'a> { + pub(super) src: &'a Document, + /// Current recursion depth, bounded by [`MAX_COPY_DEPTH`]. + depth: usize, + pub(crate) map: HashMap, + /// Source page → destination page, for rewriting `/P` and destinations. + pub(super) pages: HashMap, + /// Destination objects that are annotation dictionaries (fixed up after copying). + pub(super) annots: Vec, + /// Top-level form fields reached through widgets. + pub(super) fields: Vec, + /// Optional content groups copied: (source, destination). + pub(crate) ocgs: Vec<(ObjRef, ObjRef)>, +} + +impl<'a> Copier<'a> { + pub(crate) fn new(src: &'a Document, map: HashMap, pages: HashMap) -> Self { + Copier { src, depth: 0, map, pages, annots: Vec::new(), fields: Vec::new(), ocgs: Vec::new() } + } + + /// Copy an indirect object (once), returning its destination reference. + pub(super) fn copy_ref(&mut self, dst: &mut Document, r: ObjRef) -> Result { + if let Some(d) = self.map.get(&r) { + return Ok(*d); + } + // Reserve the number first so cycles (field ↔ widget) terminate. + let new = dst.add(Object::Null); + self.map.insert(r, new); + let obj = self.src.get(r); + if obj.as_dict().is_some_and(|d| d.name(b"Type") == Some(b"OCG")) { + self.ocgs.push((r, new)); + } + let copied = self.copy_value(dst, &obj, Some(new))?; + dst.set(new, copied); + Ok(new) + } + + /// One level deeper into the object graph, or the refusal when there are too many. + fn descend(&mut self) -> Result<(), OrganizeError> { + if self.depth >= MAX_COPY_DEPTH { + return Err(OrganizeError::Invalid(format!("the pages refer to objects nested more than {MAX_COPY_DEPTH} levels deep"))); + } + self.depth += 1; + Ok(()) + } + + pub(crate) fn copy_value(&mut self, dst: &mut Document, o: &Object, this: Option) -> Result { + if !matches!(o, Object::Ref(_) | Object::Array(_) | Object::Dict(_) | Object::Stream(_)) { + return Ok(o.clone()); + } + self.descend()?; + let copied = match o { + Object::Ref(r) => self.copy_ref(dst, *r).map(Object::Ref), + Object::Array(a) => a.iter().map(|x| self.copy_value(dst, x, None)).collect::, _>>().map(Object::Array), + Object::Dict(d) => self.copy_dict(dst, d, this).map(Object::Dict), + Object::Stream(s) => self.copy_dict(dst, &s.dict, None).map(|dict| { + let mut s = s.clone(); + s.dict = dict; + Object::Stream(s) + }), + other => Ok(other.clone()), + }; + self.depth = self.depth.saturating_sub(1); + copied + } + + fn copy_dict(&mut self, dst: &mut Document, d: &Dict, this: Option) -> Result { + let is_annot = d.name(b"Type") == Some(b"Annot") || (d.contains(b"Subtype") && d.contains(b"Rect") && !d.contains(b"Type")); + let is_field = d.contains(b"FT") || (d.contains(b"T") && d.contains(b"Kids")); + let mut out = Dict::new(); + for (k, v) in d.iter() { + if is_annot && SKIP_ON_ANNOT.contains(&k.as_slice()) { + continue; + } + // Destinations are rewritten afterwards (they may target pages copied later). + if is_annot && (k.as_slice() == b"Dest" || k.as_slice() == b"A") { + out.set(k.clone(), v.clone()); + continue; + } + out.set(k.clone(), self.copy_value(dst, v, None)?); + } + if let Some(this) = this { + if is_annot { + self.annots.push(this); + } + // A field (or widget) without a parent is a top-level field. + if is_field && !d.contains(b"Parent") { + self.fields.push(this); + } + } + Ok(out) + } + + /// Map a source destination (explicit array or reference to one) to the destination + /// document; `None` if it targets a page that was not copied. + pub(super) fn map_dest(&self, dest: &Object) -> Option { + self.map_dest_depth(dest, 0) + } + + fn map_dest_depth(&self, dest: &Object, depth: u8) -> Option { + if depth > 4 { + return None; + } + match dest { + Object::Name(n) => self.map_dest_depth(&named_dest(self.src, n)?, depth + 1), + Object::String(s) => self.map_dest_depth(&named_dest(self.src, &s.bytes)?, depth + 1), + Object::Ref(_) => self.map_dest_depth(&self.src.resolve(dest), depth + 1), + Object::Dict(d) => self.map_dest_depth(d.get(b"D")?, depth + 1), + Object::Array(a) => { + let page = a.first()?.as_ref()?; + let new_page = *self.pages.get(&page)?; + let mut a = a.clone(); + a[0] = Object::Ref(new_page); + Some(Object::Array(a)) + } + _ => None, + } + } + + /// Rewrite `/P`, `/Dest` and GoTo actions on copied annotations. + pub(crate) fn fix_annotations(&mut self, dst: &mut Document, annot_pages: &HashMap) -> Result<(), OrganizeError> { + for &a in &std::mem::take(&mut self.annots) { + let src_dict = dst.get(a).as_dict().cloned(); + let Some(d) = src_dict else { continue }; + let mut d = d; + if let Some(p) = annot_pages.get(&a) { + d.set(b"P".to_vec(), Object::Ref(*p)); + } + if let Some(dest) = d.get(b"Dest").cloned() { + let resolved = self.src.resolve(&dest); + match self.map_dest(&resolved) { + Some(nd) => d.set(b"Dest".to_vec(), nd), + None => { + d.remove(b"Dest"); + } + } + } + if let Some(action) = d.get(b"A").cloned() { + let action = self.src.resolve(&action); + if let Some(ad) = action.as_dict() { + if ad.name(b"S") == Some(b"GoTo") { + let target = ad.get(b"D").map(|x| self.src.resolve(x)); + match target.and_then(|t| self.map_dest(&t)) { + Some(nd) => { + let mut na = Dict::new(); + na.set(b"S".to_vec(), Object::name("GoTo")); + na.set(b"D".to_vec(), nd); + d.set(b"A".to_vec(), Object::Dict(na)); + } + None => { + d.remove(b"A"); + } + } + } else { + // Other actions (URI, Launch, JavaScript…) carry no page references. The + // copier lends its map out and takes it back with the new copies in it, + // so actions sharing an object share its one copy. + let mut c = Copier::new(self.src, std::mem::take(&mut self.map), HashMap::new()); + let copied = c.copy_value(dst, &Object::Dict(ad.clone()), None); + self.map = c.map; + d.set(b"A".to_vec(), copied?); + } + } + } + dst.set(a, Object::Dict(d)); + } + Ok(()) + } +} + +/// Copy `o` from `src` into `dst`, reusing the copies listed in `map` (source → destination), so +/// an object the copied pages already brought along (an ICC profile) is not stored twice. +pub(crate) fn copy_object(dst: &mut Document, src: &Document, o: &Object, map: HashMap) -> Result { + Copier::new(src, map, HashMap::new()).copy_value(dst, o, None) +} diff --git a/crates/organize/src/import/fields.rs b/crates/organize/src/import/fields.rs new file mode 100644 index 0000000000..44910d0406 --- /dev/null +++ b/crates/organize/src/import/fields.rs @@ -0,0 +1,297 @@ +use super::copy::WIDGET_KEYS; +use super::{Dict, Document, HashMap, HashSet, ObjRef, Object, OrganizeError, walk}; + +/// Rename the top-level fields about to be registered in `dst` whose fully-qualified names +/// (`parent.T.child`) are already taken, so each imported field stays its own field instead of +/// blurring into the destination's field of the same name (fields of one fully-qualified name +/// are one field). A colliding node's own `/T` becomes the first free `Name-1`, `Name-2`… +/// variant, so everything below it keeps its place in the tree. Returns what was renamed +/// (old fully-qualified name → new one), in order. +pub(super) fn resolve_field_conflicts(dst: &mut Document, fields: &[ObjRef]) -> Result, OrganizeError> { + let mut taken = taken_field_names(dst)?; + let planned = plan_renames(dst, fields, &mut taken); + let mut renamed = Vec::new(); + for (node, new_t, old, new) in planned { + dst.update_dict(node, |d| d.set(b"T".to_vec(), Object::String(pdfcraft_cos::PdfString::text(&new_t))))?; + renamed.push((old, new)); + } + Ok(renamed) +} + +/// What importing `src` into `dst` would rename, computed without changing either document: +/// each fully-qualified name of `src`'s fields that `dst` already uses (or that an earlier +/// field of `src` itself uses) is renamed to the first free variant, exactly as the import +/// would. Unreadable structure previews as nothing: the import itself will say why it failed. +pub fn field_name_conflicts(dst: &Document, src: &Document) -> Vec<(String, String)> { + let Ok(mut taken) = taken_field_names(dst) else { return Vec::new() }; + let Ok(roots) = field_roots(src) else { return Vec::new() }; + plan_renames(src, &roots, &mut taken).into_iter().map(|(_, _, old, new)| (old, new)).collect() +} + +/// What combining `sources`, in order, would rename in each one: the whole list is simulated +/// with the same first-free-variant rule the combine uses, so a later file's preview sees the +/// earlier files' renames. Unreadable structure previews as nothing (the combine itself will +/// say why it fails); non-colliding names still count as taken for the files after them. +pub fn combine_field_conflicts(sources: &[&Document]) -> Vec> { + let mut taken = std::collections::HashSet::new(); + sources + .iter() + .map(|src| match field_roots(src) { + Ok(roots) => plan_renames(src, &roots, &mut taken).into_iter().map(|(_, _, old, new)| (old, new)).collect(), + Err(_) => Vec::new(), + }) + .collect() +} + +/// Walk field trees `roots` in `doc` and plan the renames that would keep every name separate +/// from `taken`: each node's fully-qualified name joins `taken` (so a later field of the same +/// import collides with an earlier one), and a node already taken is renamed to the first free +/// variant of its own `/T`. Names are compared and renamed as decoded text, not as stored +/// bytes, so one name written in two encodings (PDFDocEncoding, UTF-16BE) is one name. +/// Read-only: returns (node, new `/T`, old name, new name). Cycle- and depth-safe. +fn plan_renames(doc: &Document, roots: &[ObjRef], taken: &mut std::collections::HashSet) -> Vec<(ObjRef, String, String, String)> { + let mut planned = Vec::new(); + let mut walk = RenameWalk::default(); + for &root in roots { + plan_renames_under(doc, root, "", taken, &mut walk, 0, &mut planned); + } + planned +} + +/// What one [`plan_renames`] call remembers: the nodes it visited, and for each colliding name +/// the suffix to try next. +#[derive(Default)] +struct RenameWalk { + seen: HashSet, + next: HashMap, +} + +/// One node of [`plan_renames`]. +fn plan_renames_under( + doc: &Document, + node: ObjRef, + prefix: &str, + taken: &mut std::collections::HashSet, + walk: &mut RenameWalk, + depth: u8, + planned: &mut Vec<(ObjRef, String, String, String)>, +) { + if depth > 32 || !walk.seen.insert(node) { + return; + } + let Some(d) = doc.get(node).as_dict().cloned() else { return }; + let mut name = d.get(b"T").and_then(|t| doc.resolve(t).as_string().map(|s| s.to_text())).unwrap_or_default(); + if !name.is_empty() { + let fqn = qualified(prefix, &name); + if taken.contains(&fqn) { + // `taken` only grows, so every suffix below the last one handed out for this name is + // still taken: resume there instead of at 1. + let mut n = walk.next.get(&fqn).copied().unwrap_or(1); + let mut variant; + while { + #[cfg(test)] + crate::work(1); + variant = format!("{name}-{n}"); + taken.contains(&qualified(prefix, &variant)) + } { + n = n.saturating_add(1); + } + walk.next.insert(fqn.clone(), n.saturating_add(1)); + let new_fqn = qualified(prefix, &variant); + planned.push((node, variant.clone(), fqn, new_fqn.clone())); + taken.insert(new_fqn); + name = variant; + } else { + taken.insert(fqn); + } + } + let prefix = qualified_optional(prefix, &name); + let kids = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default(); + for kid in kids.iter().filter_map(|k| k.as_ref()) { + plan_renames_under(doc, kid, &prefix, taken, walk, depth + 1, planned); + } +} + +/// The field names in use in `dst`: every name in its form, and every name hanging only off a +/// page's widgets — a field the form never lists still owns its name. +fn taken_field_names(dst: &Document) -> Result, OrganizeError> { + let mut taken = std::collections::HashSet::new(); + let mut seen = std::collections::HashSet::new(); + for root in field_roots(dst)? { + collect_names(dst, root, "", &mut seen, 0, &mut taken); + } + Ok(taken) +} + +/// A document's field trees, wherever they are rooted: the `/AcroForm /Fields` entries, and +/// the fields behind widgets on pages (climbed to their root, for forms that never registered +/// them). +fn field_roots(doc: &Document) -> Result, OrganizeError> { + let mut roots: Vec = doc + .root() + .and_then(|r| doc.get(r).as_dict().cloned()) + .and_then(|c| c.get(b"AcroForm").map(|f| doc.resolve(f))) + .and_then(|f| f.as_dict().cloned()) + .and_then(|f| f.get(b"Fields").map(|l| doc.resolve(l))) + .and_then(|l| l.as_array().cloned()) + .unwrap_or_default() + .iter() + .filter_map(|f| f.as_ref()) + .collect(); + for (page, _) in walk(doc)? { + let Some(d) = doc.get(page).as_dict().cloned() else { continue }; + let annots = d.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default(); + for a in annots.iter() { + let Some(w) = doc.resolve(a).as_dict().cloned() else { continue }; + if w.name(b"Subtype") != Some(b"Widget") { + continue; + } + // Walk up to the field the widget belongs to, however deep it sits; a widget that + // is its own field is its own root. + let Some(mut root) = a.as_ref() else { continue }; + let mut up = 0; + while let Some(parent) = doc.get(root).as_dict().and_then(|p| p.reference(b"Parent")) { + if up >= 32 || root == parent { + break; + } + root = parent; + up += 1; + } + roots.push(root); + } + } + Ok(roots) +} + +/// Record the fully-qualified names of one field tree. Cycle- and depth-safe. +fn collect_names( + dst: &Document, + node: ObjRef, + prefix: &str, + seen: &mut std::collections::HashSet, + depth: u8, + out: &mut std::collections::HashSet, +) { + if depth > 32 || !seen.insert(node) { + return; + } + let Some(d) = dst.get(node).as_dict().cloned() else { return }; + let name = d.get(b"T").and_then(|t| dst.resolve(t).as_string().map(|s| s.to_text())).unwrap_or_default(); + let prefix = qualified_optional(prefix, &name); + if !name.is_empty() { + out.insert(prefix.clone()); + } + let kids = d.get(b"Kids").map(|k| dst.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default(); + for kid in kids.iter().filter_map(|k| k.as_ref()) { + collect_names(dst, kid, &prefix, seen, depth + 1, out); + } +} + +/// `prefix.name`, or `name` at the top level. +fn qualified(prefix: &str, name: &str) -> String { + if prefix.is_empty() { name.to_string() } else { format!("{prefix}.{name}") } +} + +/// The prefix a node's children hang under: its own name, when it has one. +fn qualified_optional(prefix: &str, name: &str) -> String { + if name.is_empty() { prefix.to_string() } else { qualified(prefix, name) } +} + +/// Add `kid` to `field`'s `/Kids`, in the array object itself when `/Kids` is one, so the field +/// keeps its other widgets. +pub(super) fn add_kid(dst: &mut Document, field: ObjRef, kid: ObjRef) -> Result<(), OrganizeError> { + match dst.get(field).as_dict().and_then(|f| f.get(b"Kids").cloned()) { + Some(Object::Ref(list)) => { + let mut kids = dst.get(list).as_array().cloned().unwrap_or_default(); + kids.push(Object::Ref(kid)); + dst.set(list, Object::Array(kids)); + } + _ => dst.update_dict(field, |f| { + let mut kids = f.get(b"Kids").and_then(|k| k.as_array()).cloned().unwrap_or_default(); + kids.push(Object::Ref(kid)); + f.set(b"Kids".to_vec(), Object::Array(kids)); + })?, + } + Ok(()) +} + +/// PdfCraft patch (#1810): split a widget that doubles as its own field into a parent field +/// holding the field keys (name, value, flags…) and a widget child holding the annotation keys +/// (rectangle, appearance, page). The parent takes the widget's place among the top-level fields; +/// later copies of the page add widget children of it, so every page has its own widget and the +/// field stays one. +pub(super) fn split_own_field(dst: &mut Document, fields: &mut [ObjRef], widget: ObjRef) { + let Some(d) = dst.get(widget).as_dict().cloned() else { return }; + let (mut field, mut child) = field_and_widget(dst, &d); + let parent = dst.add(Object::Null); + field.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(widget)])); + child.set(b"Parent".to_vec(), Object::Ref(parent)); + dst.set(parent, Object::Dict(field)); + dst.set(widget, Object::Dict(child)); + if let Some(entry) = fields.iter_mut().find(|f| **f == widget) { + *entry = parent; + } +} + +/// A combined field/widget has two additional-action namespaces (ISO 32000-1 tables 194/196). +/// Partition them as well as the ordinary entries, including when copying a later widget. +pub(super) fn field_and_widget(doc: &Document, d: &Dict) -> (Dict, Dict) { + let (mut field, mut child) = (Dict::new(), Dict::new()); + for (k, v) in d.iter() { + if k.as_slice() == b"AA" + && let Some(actions) = doc.resolve(v).as_dict().cloned() + { + let (mut field_actions, mut widget_actions) = (Dict::new(), Dict::new()); + for (trigger, action) in actions.iter() { + if [b"E".as_slice(), b"X", b"D", b"U", b"Fo", b"Bl", b"PO", b"PC", b"PV", b"PI"].contains(&trigger.as_slice()) { + widget_actions.set(trigger.clone(), action.clone()); + } else { + field_actions.set(trigger.clone(), action.clone()); + } + } + if field_actions.iter().next().is_some() { + field.set(k.clone(), Object::Dict(field_actions)); + } + if widget_actions.iter().next().is_some() { + child.set(k.clone(), Object::Dict(widget_actions)); + } + } else if WIDGET_KEYS.contains(&k.as_slice()) { + child.set(k.clone(), v.clone()); + } else { + field.set(k.clone(), v.clone()); + } + } + (field, child) +} + +/// Add top-level fields to the destination's `/AcroForm /Fields` (creating the form if needed). +pub(super) fn register_fields(dst: &mut Document, fields: &[ObjRef]) -> Result<(), OrganizeError> { + if fields.is_empty() { + return Ok(()); + } + let root = dst.root().ok_or(OrganizeError::NoPageTree)?; + let catalog = dst.get(root).as_dict().cloned().ok_or(OrganizeError::NoPageTree)?; + let (form_ref, mut form) = match catalog.get(b"AcroForm") { + Some(Object::Ref(r)) => (Some(*r), dst.get(*r).as_dict().cloned().unwrap_or_default()), + Some(Object::Dict(d)) => (None, d.clone()), + _ => (None, Dict::new()), + }; + let mut list = form.get(b"Fields").map(|f| dst.resolve(f)).and_then(|f| f.as_array().cloned()).unwrap_or_default(); + let mut listed: HashSet = list.iter().filter_map(Object::as_ref).collect(); + for f in fields { + #[cfg(test)] + crate::work(1); + if listed.insert(*f) { + list.push(Object::Ref(*f)); + } + } + form.set(b"Fields".to_vec(), Object::Array(list)); + match form_ref { + Some(r) => dst.set(r, Object::Dict(form)), + None => { + let r = dst.add(form); + dst.update_dict(root, |c| c.set(b"AcroForm".to_vec(), Object::Ref(r)))?; + } + } + Ok(()) +} diff --git a/crates/organize/src/lib.rs b/crates/organize/src/lib.rs index e24056a9c3..b890965d0c 100644 --- a/crates/organize/src/lib.rs +++ b/crates/organize/src/lib.rs @@ -15,6 +15,7 @@ use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, XrefEntry}; mod attachments; mod boxes; +mod comments; mod dedupe; mod import; mod labels; @@ -27,6 +28,7 @@ mod xmp; pub use attachments::{add_attachment, attachment_keys, delete_attachment, mime_for_name}; pub use boxes::{BoxSpec, PageBox, page_boxes, set_page_box}; +pub use comments::import_comments; pub use dedupe::dedupe_resources; pub use import::{ CombineOutcome, Combiner, FieldConflicts, ImportOutcome, Run, SplitBy, combine, combine_field_conflicts, combine_grouped, combine_grouped_with, diff --git a/crates/organize/src/outline.rs b/crates/organize/src/outline.rs index 2bb4122d2f..ff7d3d8da7 100644 --- a/crates/organize/src/outline.rs +++ b/crates/organize/src/outline.rs @@ -518,14 +518,10 @@ fn destination_at(doc: &Document, page: usize, left: Option, top: Option Object { - go_to_at(page, None, None) -} - /// `/XYZ left top null`: the point (`left`, `top`) at the window's upper-left corner, at the -/// reader's zoom; a coordinate that is left out keeps the reader's position. +/// reader's zoom; a coordinate that is left out keeps the reader's position. With neither, +/// `/XYZ null null null` goes to the page, keeping the reader's zoom (what Acrobat writes for a +/// new bookmark when the view has no specific position). fn go_to_at(page: ObjRef, left: Option, top: Option) -> Object { let coordinate = |v: Option| v.map_or(Object::Null, Object::Real); Object::Array(vec![Object::Ref(page), Object::name("XYZ"), coordinate(left), coordinate(top), Object::Null]) @@ -569,7 +565,7 @@ pub fn add_bookmark_at( Ok(path) } -/// One bookmark of a generated tree (New Bookmarks from Structure). +/// One bookmark of a generated tree (New Bookmarks from Structure, or from text styles). #[derive(Clone, Debug, PartialEq)] pub struct OutlineEntry { /// 1 = directly under the new parent; deeper levels nest under the last shallower entry. @@ -578,6 +574,9 @@ pub struct OutlineEntry { pub page: usize, /// The structure element it stands for (`/SE`). pub element: Option, + /// Where on the page it goes: this height (user space) at the top of the window, at the + /// reader's zoom. `None` goes to the page, keeping the reader's position. + pub top: Option, } /// Add a new top-level bookmark `parent_title` (first in the list) holding `entries` nested by @@ -601,7 +600,7 @@ pub fn add_bookmark_tree(doc: &mut Document, parent_title: &str, entries: &[Outl for (e, page) in entries.iter().zip(targets) { let mut d = Dict::new(); d.set(b"Title".to_vec(), Object::String(PdfString::text(&e.title))); - d.set(b"Dest".to_vec(), go_to(page)); + d.set(b"Dest".to_vec(), go_to_at(page, None, e.top)); if let Some(se) = e.element { d.set(b"SE".to_vec(), Object::Ref(se)); } diff --git a/crates/organize/src/prune.rs b/crates/organize/src/prune.rs index d8db2f7ac4..5a2a319fb4 100644 --- a/crates/organize/src/prune.rs +++ b/crates/organize/src/prune.rs @@ -231,14 +231,15 @@ impl Pruner<'_> { } } -/// The page's content, concatenated (`/Contents` may be one stream or an array), decoded -/// against one page's [`DecodeBudget`]. An error when a stream can't be decoded or the budget -/// runs out: the page may draw names that can't be seen. +/// The page's content, concatenated (`/Contents` may be one stream or an array) with a space +/// between pieces as the renderer joins them, decoded against one page's [`DecodeBudget`]. An +/// error when a stream can't be decoded or the budget runs out: the page may draw names that +/// can't be seen. pub(crate) fn content_bytes(doc: &Document, contents: &Object) -> Result, CosError> { let mut out = Vec::new(); for data in DecodeBudget::page().decode_contents(doc, contents) { out.extend(data?); - out.push(b'\n'); + out.push(b' '); } Ok(out) } diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index 1fd60d744a..33dc262fce 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -1789,7 +1789,7 @@ fn bookmarks_add_rename_move_delete() { fn a_bookmark_tree_nests_entries_by_level_under_a_new_first_bookmark() { let mut d = doc_a(); crate::add_bookmark(&mut d, &[], 0, "Existing", 0).unwrap(); - let e = |level, title: &str, page, element| crate::OutlineEntry { level, title: title.into(), page, element }; + let e = |level, title: &str, page, element| crate::OutlineEntry { level, title: title.into(), page, element, top: None }; let se = ObjRef::new(4242, 0); let entries = [e(1, "One", 0, Some(se)), e(3, "Deep", 1, None), e(2, "Two", 1, None), e(1, "Next", 2, None)]; assert_eq!(crate::add_bookmark_tree(&mut d, "Untitled", &entries).unwrap(), vec![0]); @@ -3767,3 +3767,49 @@ fn bookmark_style_and_colour_are_set_as_the_table_says() { let got: Vec<_> = bookmarks(&saved).into_iter().map(|b| (b.color, b.bold, b.italic)).collect(); assert_eq!(got, [(Some([0.0, 0.0, 1.0]), true, true), (None, false, false)]); } + +/// #3502: a `/Contents` array's pieces are joined with a space, as the renderer joins them, so a +/// `%` comment at the end of a piece without a line break continues through the first line of the +/// next piece in both resource pruning (`extract_pages`) and `page_as_form`. +#[test] +fn contents_pieces_join_with_a_space_across_a_trailing_comment() { + let build_doc = |first_piece: &str| { + let b: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), // 1 + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>".into(), // 2 + "<< /Type /Page /Parent 2 0 R /Contents [4 0 R 5 0 R] /Resources 6 0 R >>".into(), // 3 + raw(first_piece), // 4 + raw("/ImHidden Do\n/ImShown Do"), // 5 + "<< /XObject << /ImHidden 7 0 R /ImShown 8 0 R >> >>".into(), // 6 + image(1, 1, 1), // 7 + image(1, 1, 1), // 8 + ]; + open(build(&b, "/Root 1 0 R")) + }; + + // Without a trailing newline in piece 1, `% comment` swallows `/ImHidden Do` on the first + // line of piece 2; only `/ImShown` is drawn. + let commented = build_doc("q Q % comment without newline"); + let extracted = full_roundtrip(&extract_pages(&commented, &[0]).unwrap()); + assert_eq!(xobject_names(&extracted, 0), [b"ImShown".to_vec()], "commented-out Do across piece boundary is pruned"); + + let mut form_dst = Document::new_empty(); + let (form_ref, _) = crate::page_as_form(&mut form_dst, &commented, 0).unwrap(); + let Object::Stream(form_stream) = &*form_dst.get(form_ref) else { panic!("form stream") }; + let form_bytes = form_stream.decoded().unwrap(); + let drawn: Vec> = pdfcraft_content::parse(&form_bytes) + .ops + .into_iter() + .filter(|op| op.op == b"Do") + .filter_map(|op| op.operands.first().and_then(Object::as_name).map(|n| n.to_vec())) + .collect(); + assert_eq!(drawn, [b"ImShown".to_vec()], "page_as_form keeps the first line of piece 2 inside the comment"); + + // Control: when piece 1 ends the comment with a newline, `/ImHidden Do` on piece 2's first + // line is a real operator and stays in both the extracted page and the form XObject. + let newline_ended = build_doc("q Q % comment with newline\n"); + let extracted_ctrl = full_roundtrip(&extract_pages(&newline_ended, &[0]).unwrap()); + let mut ctrl_names = xobject_names(&extracted_ctrl, 0); + ctrl_names.sort(); + assert_eq!(ctrl_names, [b"ImHidden".to_vec(), b"ImShown".to_vec()]); +} diff --git a/crates/platform/src/staging.rs b/crates/platform/src/staging.rs index 092aa65239..4316051352 100644 --- a/crates/platform/src/staging.rs +++ b/crates/platform/src/staging.rs @@ -292,455 +292,4 @@ fn sync_staged(f: &std::fs::File, sync_all: impl FnOnce(&std::fs::File) -> std:: } #[cfg(test)] -mod tests { - use super::*; - - /// A fresh, empty folder for one test. - fn test_dir(test: &str) -> PathBuf { - let dir = std::env::temp_dir().join(format!("pdfcraft-platform-staging-{}-{test}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - dir - } - - fn file_name(p: &Path) -> String { - p.file_name().unwrap().to_string_lossy().into_owned() - } - - /// The callers' tests plant files at these exact names; if a name changed, those tests would - /// plant where nothing writes and pass without testing anything. - #[test] - fn each_form_keeps_its_exact_name() { - let dir = test_dir("forms"); - let (tools, _) = create_staging(&dir, "out.pdf", StagingName::SuffixThenTag, [0xab]).unwrap(); - assert_eq!(file_name(&tools), ".out.pdf.00000000000000ab.pdfcraft-tmp"); - let (desktop, _) = create_staging(&dir, "out.pdf", StagingName::TagThenSuffix, [0xab]).unwrap(); - assert_eq!(file_name(&desktop), ".out.pdf.pdfcraft-00000000000000ab.tmp"); - assert_eq!(std::fs::read(&tools).unwrap(), b"", "a new, empty file"); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn a_long_name_is_cut_to_128_bytes_between_characters() { - let dir = test_dir("long"); - // One byte, then four-byte characters: the 128-byte limit falls inside the 32nd one. - let name = format!("a{}.pdf", "\u{1F600}".repeat(40)); - let (tmp, _) = create_staging(&dir, &name, StagingName::SuffixThenTag, [1]).unwrap(); - assert_eq!(file_name(&tmp), format!(".a{}.0000000000000001.pdfcraft-tmp", "\u{1F600}".repeat(31))); - // One-byte characters: exactly 128 of them are kept. - let (tmp, _) = create_staging(&dir, &"x".repeat(200), StagingName::SuffixThenTag, [2]).unwrap(); - assert_eq!(file_name(&tmp), format!(".{}.0000000000000002.pdfcraft-tmp", "x".repeat(128))); - let _ = std::fs::remove_dir_all(&dir); - } - - /// #2658: a read-only file is refused before anything is staged, on Unix (where a rename would - /// replace it) as on Windows (where the rename fails). - #[test] - fn a_read_only_target_is_refused_and_kept() { - let dir = test_dir("readonly"); - let target = dir.join("out.pdf"); - std::fs::write(&target, "OLD").unwrap(); - let mut perms = std::fs::metadata(&target).unwrap().permissions(); - perms.set_readonly(true); - std::fs::set_permissions(&target, perms.clone()).unwrap(); - let staged = std::cell::Cell::new(false); - let e = write_atomically_with(&target, b"NEW", StagingName::TagThenSuffix, [7], |_: &std::fs::File| { - staged.set(true); - Ok(()) - }) - .unwrap_err(); - assert_eq!(e.kind(), std::io::ErrorKind::PermissionDenied, "{e}"); - assert!(!staged.get(), "nothing was staged"); - assert_eq!(std::fs::read(&target).unwrap(), b"OLD"); - assert!(std::fs::metadata(&target).unwrap().permissions().readonly(), "still read-only"); - assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1, "no staging file is left"); - #[allow(clippy::permissions_set_readonly_false)] - perms.set_readonly(false); - std::fs::set_permissions(&target, perms).unwrap(); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn running_out_of_suffixes_refuses_and_creates_nothing() { - let dir = test_dir("empty"); - let e = create_staging(&dir, "out.pdf", StagingName::TagThenSuffix, []).unwrap_err(); - assert_eq!(e.kind(), std::io::ErrorKind::AlreadyExists); - assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn only_the_first_sixteen_names_are_tried() { - let dir = test_dir("cap"); - let name = |s: u64| dir.join(format!(".out.pdf.{s:016x}.pdfcraft-tmp")); - for s in 1..=STAGING_ATTEMPTS as u64 { - std::fs::write(name(s), "PLANTED").unwrap(); - } - assert!(create_staging(&dir, "out.pdf", StagingName::SuffixThenTag, 1..).is_err()); - assert!(!name(STAGING_ATTEMPTS as u64 + 1).exists(), "a 17th name was tried"); - let _ = std::fs::remove_dir_all(&dir); - } - - /// `write_atomically`: the target holds the new bytes only once they are complete; a refused - /// flush leaves the old file and removes the staging file; a missing folder writes nothing. - #[test] - fn an_atomic_write_replaces_the_target_only_when_complete() { - let dir = test_dir("write"); - let target = dir.join("out.pdf"); - std::fs::write(&target, b"OLD").unwrap(); - let names = |dir: &Path| { - let mut v: Vec = std::fs::read_dir(dir).unwrap().map(|e| file_name(&e.unwrap().path())).collect(); - v.sort(); - v - }; - // The staging file is written and flushed before the target changes. - let mut seen_old = false; - write_atomically_with(&target, b"NEW", StagingName::SuffixThenTag, [7], |f| { - seen_old = std::fs::read(&target).unwrap() == b"OLD" && f.metadata().unwrap().len() == 3; - Ok(()) - }) - .unwrap(); - assert!(seen_old, "flushed before publication, with the target still the old file"); - assert_eq!(std::fs::read(&target).unwrap(), b"NEW"); - assert_eq!(names(&dir), ["out.pdf"], "no staging file is left"); - // A refused flush keeps the target and cleans up. - let e = write_atomically_with(&target, b"NEWER", StagingName::TagThenSuffix, [8], |_| Err(std::io::Error::other("refused"))).unwrap_err(); - assert!(e.to_string().contains("refused")); - assert_eq!(std::fs::read(&target).unwrap(), b"NEW"); - assert_eq!(names(&dir), ["out.pdf"]); - // A folder that does not exist: nothing is written anywhere. - assert!(write_atomically(&dir.join("missing").join("out.pdf"), b"X", StagingName::SuffixThenTag).is_err()); - assert_eq!(names(&dir), ["out.pdf"]); - let _ = std::fs::remove_dir_all(&dir); - } - - #[cfg(target_os = "macos")] - #[test] - fn atomic_saves_keep_finder_tags_and_binary_extended_attributes() { - use rustix::fs::{XattrFlags, fgetxattr, setxattr}; - use std::os::macos::fs::FileTimesExt as _; - let dir = test_dir("xattrs"); - let target = dir.join("out.pdf"); - let attributes: &[(&str, &[u8])] = &[ - ("com.apple.metadata:_kMDItemUserTags", b"PdfCraft\n6"), - ("com.pdfcraft.test.binary", b"\0\xff\x80original\0"), - ("com.pdfcraft.test.empty", b""), - ]; - std::fs::write(&target, b"OLD").unwrap(); - for (name, value) in attributes { - setxattr(&target, *name, value, XattrFlags::empty()).unwrap(); - } - let created = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_577_934_245); - let original = std::fs::OpenOptions::new().write(true).open(&target).unwrap(); - original.set_times(std::fs::FileTimes::new().set_created(created)).unwrap(); - assert_eq!(original.metadata().unwrap().created().unwrap(), created); - drop(original); - // Both desktop and automation name forms, including a second save of the replacement. - for form in [StagingName::TagThenSuffix, StagingName::SuffixThenTag] { - write_atomically_with(&target, b"NEW", form, [7], |staged| { - assert_eq!(staged.metadata().unwrap().created().unwrap(), created, "creation date must survive attribute copying before flush"); - for (name, value) in attributes { - let mut bytes = [0u8; 256]; - let len = fgetxattr(staged, *name, &mut bytes[..]).unwrap(); - assert_eq!(&bytes[..len], *value, "metadata must be present before flush: {name}"); - } - Ok(()) - }) - .unwrap(); - let saved = std::fs::File::open(&target).unwrap(); - assert_eq!(saved.metadata().unwrap().created().unwrap(), created); - for (name, value) in attributes { - let mut bytes = [0u8; 256]; - let len = fgetxattr(&saved, *name, &mut bytes[..]).unwrap(); - assert_eq!(&bytes[..len], *value); - } - assert_eq!(std::fs::read(&target).unwrap(), b"NEW"); - assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1); - } - std::fs::remove_dir_all(dir).unwrap(); - } - - #[cfg(target_os = "macos")] - #[test] - fn a_failed_flush_keeps_the_original_extended_attributes() { - use rustix::fs::{XattrFlags, getxattr, setxattr}; - let dir = test_dir("xattrs-flush"); - let target = dir.join("out.pdf"); - std::fs::write(&target, b"OLD").unwrap(); - setxattr(&target, "com.pdfcraft.test", b"original", XattrFlags::empty()).unwrap(); - let e = write_atomically_with(&target, b"NEW", StagingName::TagThenSuffix, [9], |_| Err(std::io::Error::other("flush refused"))).unwrap_err(); - assert!(e.to_string().contains("flush refused")); - assert_eq!(std::fs::read(&target).unwrap(), b"OLD"); - let mut value = [0u8; 64]; - let len = getxattr(&target, "com.pdfcraft.test", &mut value[..]).unwrap(); - assert_eq!(&value[..len], b"original"); - assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1); - std::fs::remove_dir_all(dir).unwrap(); - } - - /// Model an HFS-style partial read while using real macOS metadata and the atomic writer. - /// The shared read validator must reject growth before the staging file can be published. - #[cfg(target_os = "macos")] - #[test] - fn a_growing_resource_fork_refuses_publication_and_keeps_original() { - use rustix::fs::{XattrFlags, fgetxattr, fsetxattr, getxattr, setxattr}; - let dir = test_dir("xattrs-growing-fork"); - let target = dir.join("out.pdf"); - let name = "com.apple.ResourceFork"; - for form in [StagingName::TagThenSuffix, StagingName::SuffixThenTag] { - std::fs::write(&target, b"ORIGINAL PDF").unwrap(); - setxattr(&target, name, b"ABC", XattrFlags::empty()).unwrap(); - let source = std::fs::File::open(&target).unwrap(); - let result = write_atomically_with(&target, b"REPLACEMENT PDF", form, [23], |staged| { - let size = fgetxattr(&source, name, &mut [] as &mut [u8])?; - let value = read_xattr_value(size, |buffer| { - if buffer.is_empty() { - return Ok(fgetxattr(&source, name, buffer)?); - } - // HFS can return the prefix that fits instead of ERANGE. APFS need not - // have that behaviour: model the kernel response, not a timing race. - setxattr(&target, name, b"ABCD", XattrFlags::empty())?; - buffer.get_mut(..3).unwrap().copy_from_slice(b"ABC"); - Ok(3) - })?; - fsetxattr(staged, name, &value, XattrFlags::empty())?; - Ok(()) - }); - let error = result.expect_err("a short prefix of the grown fork must not be published"); - assert!(error.to_string().contains("changed size"), "{error}"); - assert_eq!(std::fs::read(&target).unwrap(), b"ORIGINAL PDF"); - let mut bytes = [0u8; 8]; - let len = getxattr(&target, name, &mut bytes[..]).unwrap(); - assert_eq!(&bytes[..len], b"ABCD", "the original's grown fork must remain intact"); - assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1, "staging files are removed on refusal"); - } - std::fs::remove_dir_all(dir).unwrap(); - } - - /// Stable empty/binary values are accepted; short reads, growth and failed rechecks are refused. - #[cfg(target_os = "macos")] - #[test] - fn attribute_reads_require_a_complete_value_and_stable_size() { - for bytes in [&b""[..], &b"\0\xffB"[..]] { - let value = read_xattr_value(bytes.len(), |buffer| { - if !buffer.is_empty() { - buffer.get_mut(..bytes.len()).unwrap().copy_from_slice(bytes); - } - Ok(bytes.len()) - }) - .unwrap(); - assert_eq!(value, bytes); - } - for (before, read_len, after) in [(3, 2, 3), (3, 3, 4), (0, 1, 1), (3, 2, 2)] { - let result = read_xattr_value(before, |buffer| Ok(if buffer.is_empty() { after } else { read_len })); - assert!(result.unwrap_err().to_string().contains("changed size")); - } - let result = read_xattr_value(3, |buffer| { - if buffer.is_empty() { Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "size recheck refused")) } else { Ok(3) } - }); - assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::PermissionDenied); - } - - #[cfg(target_os = "macos")] - #[test] - fn oversized_extended_attributes_refuse_the_save_without_replacing_the_target() { - use rustix::fs::{XattrFlags, getxattr, setxattr}; - let dir = test_dir("xattrs-limit"); - let target = dir.join("out.pdf"); - std::fs::write(&target, b"OLD").unwrap(); - // A resource fork can exceed the ordinary attribute limit on macOS. No fixture file - // is committed; only the OS metadata of this temporary synthetic file holds the bytes. - let fork = vec![42u8; 16 * 1024 * 1024 + 1]; - setxattr(&target, "com.apple.ResourceFork", &fork, XattrFlags::empty()).unwrap(); - let mut flushed = false; - let e = write_atomically_with(&target, b"NEW", StagingName::TagThenSuffix, [10], |_| { - flushed = true; - Ok(()) - }) - .unwrap_err(); - assert!(e.to_string().contains("extended attributes"), "{e}"); - assert!(e.to_string().contains("Save As"), "{e}"); - assert!(!flushed, "metadata failure must stop before flush and rename"); - assert_eq!(std::fs::read(&target).unwrap(), b"OLD"); - let size = getxattr(&target, "com.apple.ResourceFork", &mut [] as &mut [u8]).unwrap(); - assert_eq!(size, fork.len()); - assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1); - std::fs::remove_dir_all(dir).unwrap(); - } - - #[cfg(target_os = "macos")] - #[test] - fn replacing_a_symlink_does_not_copy_attributes_from_its_referent() { - use rustix::fs::{XattrFlags, getxattr, setxattr}; - let dir = test_dir("xattrs-symlink"); - let outside = test_dir("xattrs-outside").join("private.pdf"); - std::fs::write(&outside, b"PRIVATE").unwrap(); - setxattr(&outside, "com.pdfcraft.test", b"private metadata", XattrFlags::empty()).unwrap(); - let target = dir.join("out.pdf"); - std::os::unix::fs::symlink(&outside, &target).unwrap(); - write_atomically(&target, b"NEW", StagingName::TagThenSuffix).unwrap(); - assert_eq!(std::fs::read(&outside).unwrap(), b"PRIVATE"); - assert_eq!(std::fs::read(&target).unwrap(), b"NEW"); - assert!(!target.symlink_metadata().unwrap().file_type().is_symlink()); - let mut bytes = [0u8; 64]; - assert_eq!(getxattr(&target, "com.pdfcraft.test", &mut bytes[..]).unwrap_err(), rustix::io::Errno::NOATTR); - let len = getxattr(&outside, "com.pdfcraft.test", &mut bytes[..]).unwrap(); - assert_eq!(&bytes[..len], b"private metadata"); - std::fs::remove_dir_all(dir).unwrap(); - std::fs::remove_dir_all(outside.parent().unwrap()).unwrap(); - } - - /// #1790: a replaced file keeps its permissions, and its staging file already has them while - /// it is written; a new file gets the same mode as any other new file. - #[cfg(unix)] - #[test] - fn a_replaced_file_keeps_its_permissions() { - use std::os::unix::fs::PermissionsExt as _; - let dir = test_dir("modes"); - let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777; - let target = dir.join("out.pdf"); - for keep in [0o600, 0o640, 0o664] { - std::fs::write(&target, b"OLD").unwrap(); - std::fs::set_permissions(&target, std::fs::Permissions::from_mode(keep)).unwrap(); - let mut staged = 0; - write_atomically_with(&target, b"NEW", StagingName::SuffixThenTag, [keep as u64], |f| { - staged = f.metadata()?.permissions().mode() & 0o777; - Ok(()) - }) - .unwrap(); - assert_eq!(staged, keep, "the staging file has the target's mode before it is flushed"); - assert_eq!(mode(&target), keep, "the saved file keeps mode {keep:o}"); - assert_eq!(std::fs::read(&target).unwrap(), b"NEW"); - } - let fresh = dir.join("new.pdf"); - write_atomically(&fresh, b"NEW", StagingName::TagThenSuffix).unwrap(); - let plain = dir.join("plain"); - std::fs::write(&plain, b"").unwrap(); - assert_eq!(mode(&fresh), mode(&plain), "a new file is not made private"); - let _ = std::fs::remove_dir_all(&dir); - } - - /// A replaced file keeps its group as well as its mode, so group permissions never reach a - /// group that couldn't read the file before (the saver's own group, by default). - #[cfg(target_os = "linux")] - #[test] - fn a_replaced_file_keeps_its_group() { - use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _}; - let dir = test_dir("group"); - let target = dir.join("out.pdf"); - std::fs::write(&target, b"OLD").unwrap(); - let created = std::fs::metadata(&target).unwrap().gid(); - let status = std::fs::read_to_string("/proc/self/status").unwrap(); - let groups = status.lines().find_map(|l| l.strip_prefix("Groups:")).unwrap_or_default(); - let Some(other) = groups.split_whitespace().filter_map(|g| g.parse::().ok()).find(|&g| g != created) else { - eprintln!("skipped: the test needs a second group"); - return; - }; - std::os::unix::fs::chown(&target, None, Some(other)).unwrap(); - std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o640)).unwrap(); - let mut staged = (0, 0); - write_atomically_with(&target, b"NEW", StagingName::SuffixThenTag, [7], |f| { - let m = f.metadata()?; - staged = (m.gid(), m.permissions().mode() & 0o777); - Ok(()) - }) - .unwrap(); - assert_eq!(staged, (other, 0o640), "the staging file has the target's group before it is flushed"); - let m = std::fs::metadata(&target).unwrap(); - assert_eq!((m.gid(), m.permissions().mode() & 0o777), (other, 0o640)); - let _ = std::fs::remove_dir_all(&dir); - } - - /// #2658: a save replaces the file, but the document is the same one: it keeps its creation - /// date where the system lets a program set it. - #[cfg(any(windows, target_os = "macos"))] - #[test] - fn a_replaced_file_keeps_its_creation_date() { - #[cfg(target_os = "macos")] - use std::os::macos::fs::FileTimesExt as _; - #[cfg(windows)] - use std::os::windows::fs::FileTimesExt as _; - let dir = test_dir("created"); - let target = dir.join("doc.pdf"); - std::fs::write(&target, b"OLD").unwrap(); - // 2020-01-02 03:04:05 UTC. - let then = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_577_934_245); - let file = std::fs::OpenOptions::new().write(true).open(&target).unwrap(); - file.set_times(std::fs::FileTimes::new().set_created(then)).unwrap(); - drop(file); - assert_eq!(std::fs::metadata(&target).unwrap().created().unwrap(), then, "the fixture's date is set"); - write_atomically(&target, b"NEW", StagingName::SuffixThenTag).unwrap(); - assert_eq!(std::fs::read(&target).unwrap(), b"NEW"); - assert_eq!(std::fs::metadata(&target).unwrap().created().unwrap(), then, "the saved file keeps its creation date"); - // A new file gets a date of its own. - let fresh = dir.join("new.pdf"); - write_atomically(&fresh, b"NEW", StagingName::SuffixThenTag).unwrap(); - assert!(std::fs::metadata(&fresh).unwrap().created().unwrap() > then); - let _ = std::fs::remove_dir_all(&dir); - } - - /// #4593: a save replaces the file, so a hidden or system file stays hidden or system, and - /// its staging file already is while it is written; a plain file stays plain, though the - /// staging name starts with a dot; a new file gets no attribute from anywhere. - #[cfg(windows)] - #[test] - fn a_replaced_file_keeps_its_hidden_and_system_attributes() { - use std::os::windows::fs::{MetadataExt as _, OpenOptionsExt as _}; - const HIDDEN: u32 = 0x2; - const SYSTEM: u32 = 0x4; - let dir = test_dir("attributes"); - let kept = |p: &Path| std::fs::metadata(p).unwrap().file_attributes() & (HIDDEN | SYSTEM); - for (i, keep) in [0, HIDDEN, SYSTEM, HIDDEN | SYSTEM].into_iter().enumerate() { - let target = dir.join(format!("doc{i}.pdf")); - let mut fixture = std::fs::OpenOptions::new().write(true).create_new(true).attributes(keep).open(&target).unwrap(); - std::io::Write::write_all(&mut fixture, b"OLD").unwrap(); - drop(fixture); - assert_eq!(kept(&target), keep, "the fixture has attributes {keep:#x}"); - for form in [StagingName::TagThenSuffix, StagingName::SuffixThenTag] { - let mut staged = u32::MAX; - write_atomically_with(&target, b"NEW", form, [i as u64], |f| { - staged = f.metadata()?.file_attributes() & (HIDDEN | SYSTEM); - Ok(()) - }) - .unwrap(); - assert_eq!(staged, keep, "the staging file has the target's attributes before it is flushed"); - assert_eq!(kept(&target), keep, "the saved file keeps attributes {keep:#x}"); - assert_eq!(std::fs::read(&target).unwrap(), b"NEW"); - } - } - let fresh = dir.join("new.pdf"); - write_atomically(&fresh, b"NEW", StagingName::TagThenSuffix).unwrap(); - assert_eq!(kept(&fresh), 0, "a new file is neither hidden nor system"); - let _ = std::fs::remove_dir_all(&dir); - } - - /// A save replaces a symbolic link, not the file it points to, so the saved file takes the - /// link's own attributes: a plain link to a hidden file saves as a plain file, and the file - /// it pointed to is left as it was. - #[cfg(windows)] - #[test] - fn a_replaced_link_keeps_its_own_attributes_not_its_targets() { - use std::os::windows::fs::{MetadataExt as _, OpenOptionsExt as _}; - const HIDDEN: u32 = 0x2; - let dir = test_dir("link-attributes"); - let referent = dir.join("referent.pdf"); - let mut fixture = std::fs::OpenOptions::new().write(true).create_new(true).attributes(HIDDEN).open(&referent).unwrap(); - std::io::Write::write_all(&mut fixture, b"OLD").unwrap(); - drop(fixture); - let link = dir.join("link.pdf"); - if let Err(e) = std::os::windows::fs::symlink_file(&referent, &link) { - eprintln!("skipped: creating a symbolic link needs Developer Mode or administrator rights ({e})"); - let _ = std::fs::remove_dir_all(&dir); - return; - } - assert_eq!(std::fs::symlink_metadata(&link).unwrap().file_attributes() & HIDDEN, 0, "the link itself is visible"); - write_atomically(&link, b"NEW", StagingName::TagThenSuffix).unwrap(); - let saved = std::fs::symlink_metadata(&link).unwrap(); - assert!(!saved.file_type().is_symlink(), "the save replaced the link"); - assert_eq!(saved.file_attributes() & HIDDEN, 0, "the link was visible, so the saved file is"); - assert_eq!(std::fs::read(&link).unwrap(), b"NEW"); - assert_eq!(std::fs::read(&referent).unwrap(), b"OLD", "the file the link pointed to is untouched"); - assert_ne!(std::fs::metadata(&referent).unwrap().file_attributes() & HIDDEN, 0, "and still hidden"); - let _ = std::fs::remove_dir_all(&dir); - } -} +mod tests; diff --git a/crates/platform/src/staging/tests.rs b/crates/platform/src/staging/tests.rs new file mode 100644 index 0000000000..b5552a2c7b --- /dev/null +++ b/crates/platform/src/staging/tests.rs @@ -0,0 +1,450 @@ +use super::*; + +/// A fresh, empty folder for one test. +fn test_dir(test: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("pdfcraft-platform-staging-{}-{test}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +fn file_name(p: &Path) -> String { + p.file_name().unwrap().to_string_lossy().into_owned() +} + +/// The callers' tests plant files at these exact names; if a name changed, those tests would +/// plant where nothing writes and pass without testing anything. +#[test] +fn each_form_keeps_its_exact_name() { + let dir = test_dir("forms"); + let (tools, _) = create_staging(&dir, "out.pdf", StagingName::SuffixThenTag, [0xab]).unwrap(); + assert_eq!(file_name(&tools), ".out.pdf.00000000000000ab.pdfcraft-tmp"); + let (desktop, _) = create_staging(&dir, "out.pdf", StagingName::TagThenSuffix, [0xab]).unwrap(); + assert_eq!(file_name(&desktop), ".out.pdf.pdfcraft-00000000000000ab.tmp"); + assert_eq!(std::fs::read(&tools).unwrap(), b"", "a new, empty file"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn a_long_name_is_cut_to_128_bytes_between_characters() { + let dir = test_dir("long"); + // One byte, then four-byte characters: the 128-byte limit falls inside the 32nd one. + let name = format!("a{}.pdf", "\u{1F600}".repeat(40)); + let (tmp, _) = create_staging(&dir, &name, StagingName::SuffixThenTag, [1]).unwrap(); + assert_eq!(file_name(&tmp), format!(".a{}.0000000000000001.pdfcraft-tmp", "\u{1F600}".repeat(31))); + // One-byte characters: exactly 128 of them are kept. + let (tmp, _) = create_staging(&dir, &"x".repeat(200), StagingName::SuffixThenTag, [2]).unwrap(); + assert_eq!(file_name(&tmp), format!(".{}.0000000000000002.pdfcraft-tmp", "x".repeat(128))); + let _ = std::fs::remove_dir_all(&dir); +} + +/// #2658: a read-only file is refused before anything is staged, on Unix (where a rename would +/// replace it) as on Windows (where the rename fails). +#[test] +fn a_read_only_target_is_refused_and_kept() { + let dir = test_dir("readonly"); + let target = dir.join("out.pdf"); + std::fs::write(&target, "OLD").unwrap(); + let mut perms = std::fs::metadata(&target).unwrap().permissions(); + perms.set_readonly(true); + std::fs::set_permissions(&target, perms.clone()).unwrap(); + let staged = std::cell::Cell::new(false); + let e = write_atomically_with(&target, b"NEW", StagingName::TagThenSuffix, [7], |_: &std::fs::File| { + staged.set(true); + Ok(()) + }) + .unwrap_err(); + assert_eq!(e.kind(), std::io::ErrorKind::PermissionDenied, "{e}"); + assert!(!staged.get(), "nothing was staged"); + assert_eq!(std::fs::read(&target).unwrap(), b"OLD"); + assert!(std::fs::metadata(&target).unwrap().permissions().readonly(), "still read-only"); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1, "no staging file is left"); + #[allow(clippy::permissions_set_readonly_false)] + perms.set_readonly(false); + std::fs::set_permissions(&target, perms).unwrap(); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn running_out_of_suffixes_refuses_and_creates_nothing() { + let dir = test_dir("empty"); + let e = create_staging(&dir, "out.pdf", StagingName::TagThenSuffix, []).unwrap_err(); + assert_eq!(e.kind(), std::io::ErrorKind::AlreadyExists); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn only_the_first_sixteen_names_are_tried() { + let dir = test_dir("cap"); + let name = |s: u64| dir.join(format!(".out.pdf.{s:016x}.pdfcraft-tmp")); + for s in 1..=STAGING_ATTEMPTS as u64 { + std::fs::write(name(s), "PLANTED").unwrap(); + } + assert!(create_staging(&dir, "out.pdf", StagingName::SuffixThenTag, 1..).is_err()); + assert!(!name(STAGING_ATTEMPTS as u64 + 1).exists(), "a 17th name was tried"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// `write_atomically`: the target holds the new bytes only once they are complete; a refused +/// flush leaves the old file and removes the staging file; a missing folder writes nothing. +#[test] +fn an_atomic_write_replaces_the_target_only_when_complete() { + let dir = test_dir("write"); + let target = dir.join("out.pdf"); + std::fs::write(&target, b"OLD").unwrap(); + let names = |dir: &Path| { + let mut v: Vec = std::fs::read_dir(dir).unwrap().map(|e| file_name(&e.unwrap().path())).collect(); + v.sort(); + v + }; + // The staging file is written and flushed before the target changes. + let mut seen_old = false; + write_atomically_with(&target, b"NEW", StagingName::SuffixThenTag, [7], |f| { + seen_old = std::fs::read(&target).unwrap() == b"OLD" && f.metadata().unwrap().len() == 3; + Ok(()) + }) + .unwrap(); + assert!(seen_old, "flushed before publication, with the target still the old file"); + assert_eq!(std::fs::read(&target).unwrap(), b"NEW"); + assert_eq!(names(&dir), ["out.pdf"], "no staging file is left"); + // A refused flush keeps the target and cleans up. + let e = write_atomically_with(&target, b"NEWER", StagingName::TagThenSuffix, [8], |_| Err(std::io::Error::other("refused"))).unwrap_err(); + assert!(e.to_string().contains("refused")); + assert_eq!(std::fs::read(&target).unwrap(), b"NEW"); + assert_eq!(names(&dir), ["out.pdf"]); + // A folder that does not exist: nothing is written anywhere. + assert!(write_atomically(&dir.join("missing").join("out.pdf"), b"X", StagingName::SuffixThenTag).is_err()); + assert_eq!(names(&dir), ["out.pdf"]); + let _ = std::fs::remove_dir_all(&dir); +} + +#[cfg(target_os = "macos")] +#[test] +fn atomic_saves_keep_finder_tags_and_binary_extended_attributes() { + use rustix::fs::{XattrFlags, fgetxattr, setxattr}; + use std::os::macos::fs::FileTimesExt as _; + let dir = test_dir("xattrs"); + let target = dir.join("out.pdf"); + let attributes: &[(&str, &[u8])] = &[ + ("com.apple.metadata:_kMDItemUserTags", b"PdfCraft\n6"), + ("com.pdfcraft.test.binary", b"\0\xff\x80original\0"), + ("com.pdfcraft.test.empty", b""), + ]; + std::fs::write(&target, b"OLD").unwrap(); + for (name, value) in attributes { + setxattr(&target, *name, value, XattrFlags::empty()).unwrap(); + } + let created = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_577_934_245); + let original = std::fs::OpenOptions::new().write(true).open(&target).unwrap(); + original.set_times(std::fs::FileTimes::new().set_created(created)).unwrap(); + assert_eq!(original.metadata().unwrap().created().unwrap(), created); + drop(original); + // Both desktop and automation name forms, including a second save of the replacement. + for form in [StagingName::TagThenSuffix, StagingName::SuffixThenTag] { + write_atomically_with(&target, b"NEW", form, [7], |staged| { + assert_eq!(staged.metadata().unwrap().created().unwrap(), created, "creation date must survive attribute copying before flush"); + for (name, value) in attributes { + let mut bytes = [0u8; 256]; + let len = fgetxattr(staged, *name, &mut bytes[..]).unwrap(); + assert_eq!(&bytes[..len], *value, "metadata must be present before flush: {name}"); + } + Ok(()) + }) + .unwrap(); + let saved = std::fs::File::open(&target).unwrap(); + assert_eq!(saved.metadata().unwrap().created().unwrap(), created); + for (name, value) in attributes { + let mut bytes = [0u8; 256]; + let len = fgetxattr(&saved, *name, &mut bytes[..]).unwrap(); + assert_eq!(&bytes[..len], *value); + } + assert_eq!(std::fs::read(&target).unwrap(), b"NEW"); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1); + } + std::fs::remove_dir_all(dir).unwrap(); +} + +#[cfg(target_os = "macos")] +#[test] +fn a_failed_flush_keeps_the_original_extended_attributes() { + use rustix::fs::{XattrFlags, getxattr, setxattr}; + let dir = test_dir("xattrs-flush"); + let target = dir.join("out.pdf"); + std::fs::write(&target, b"OLD").unwrap(); + setxattr(&target, "com.pdfcraft.test", b"original", XattrFlags::empty()).unwrap(); + let e = write_atomically_with(&target, b"NEW", StagingName::TagThenSuffix, [9], |_| Err(std::io::Error::other("flush refused"))).unwrap_err(); + assert!(e.to_string().contains("flush refused")); + assert_eq!(std::fs::read(&target).unwrap(), b"OLD"); + let mut value = [0u8; 64]; + let len = getxattr(&target, "com.pdfcraft.test", &mut value[..]).unwrap(); + assert_eq!(&value[..len], b"original"); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1); + std::fs::remove_dir_all(dir).unwrap(); +} + +/// Model an HFS-style partial read while using real macOS metadata and the atomic writer. +/// The shared read validator must reject growth before the staging file can be published. +#[cfg(target_os = "macos")] +#[test] +fn a_growing_resource_fork_refuses_publication_and_keeps_original() { + use rustix::fs::{XattrFlags, fgetxattr, fsetxattr, getxattr, setxattr}; + let dir = test_dir("xattrs-growing-fork"); + let target = dir.join("out.pdf"); + let name = "com.apple.ResourceFork"; + for form in [StagingName::TagThenSuffix, StagingName::SuffixThenTag] { + std::fs::write(&target, b"ORIGINAL PDF").unwrap(); + setxattr(&target, name, b"ABC", XattrFlags::empty()).unwrap(); + let source = std::fs::File::open(&target).unwrap(); + let result = write_atomically_with(&target, b"REPLACEMENT PDF", form, [23], |staged| { + let size = fgetxattr(&source, name, &mut [] as &mut [u8])?; + let value = read_xattr_value(size, |buffer| { + if buffer.is_empty() { + return Ok(fgetxattr(&source, name, buffer)?); + } + // HFS can return the prefix that fits instead of ERANGE. APFS need not + // have that behaviour: model the kernel response, not a timing race. + setxattr(&target, name, b"ABCD", XattrFlags::empty())?; + buffer.get_mut(..3).unwrap().copy_from_slice(b"ABC"); + Ok(3) + })?; + fsetxattr(staged, name, &value, XattrFlags::empty())?; + Ok(()) + }); + let error = result.expect_err("a short prefix of the grown fork must not be published"); + assert!(error.to_string().contains("changed size"), "{error}"); + assert_eq!(std::fs::read(&target).unwrap(), b"ORIGINAL PDF"); + let mut bytes = [0u8; 8]; + let len = getxattr(&target, name, &mut bytes[..]).unwrap(); + assert_eq!(&bytes[..len], b"ABCD", "the original's grown fork must remain intact"); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1, "staging files are removed on refusal"); + } + std::fs::remove_dir_all(dir).unwrap(); +} + +/// Stable empty/binary values are accepted; short reads, growth and failed rechecks are refused. +#[cfg(target_os = "macos")] +#[test] +fn attribute_reads_require_a_complete_value_and_stable_size() { + for bytes in [&b""[..], &b"\0\xffB"[..]] { + let value = read_xattr_value(bytes.len(), |buffer| { + if !buffer.is_empty() { + buffer.get_mut(..bytes.len()).unwrap().copy_from_slice(bytes); + } + Ok(bytes.len()) + }) + .unwrap(); + assert_eq!(value, bytes); + } + for (before, read_len, after) in [(3, 2, 3), (3, 3, 4), (0, 1, 1), (3, 2, 2)] { + let result = read_xattr_value(before, |buffer| Ok(if buffer.is_empty() { after } else { read_len })); + assert!(result.unwrap_err().to_string().contains("changed size")); + } + let result = read_xattr_value(3, |buffer| { + if buffer.is_empty() { Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "size recheck refused")) } else { Ok(3) } + }); + assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::PermissionDenied); +} + +#[cfg(target_os = "macos")] +#[test] +fn oversized_extended_attributes_refuse_the_save_without_replacing_the_target() { + use rustix::fs::{XattrFlags, getxattr, setxattr}; + let dir = test_dir("xattrs-limit"); + let target = dir.join("out.pdf"); + std::fs::write(&target, b"OLD").unwrap(); + // A resource fork can exceed the ordinary attribute limit on macOS. No fixture file + // is committed; only the OS metadata of this temporary synthetic file holds the bytes. + let fork = vec![42u8; 16 * 1024 * 1024 + 1]; + setxattr(&target, "com.apple.ResourceFork", &fork, XattrFlags::empty()).unwrap(); + let mut flushed = false; + let e = write_atomically_with(&target, b"NEW", StagingName::TagThenSuffix, [10], |_| { + flushed = true; + Ok(()) + }) + .unwrap_err(); + assert!(e.to_string().contains("extended attributes"), "{e}"); + assert!(e.to_string().contains("Save As"), "{e}"); + assert!(!flushed, "metadata failure must stop before flush and rename"); + assert_eq!(std::fs::read(&target).unwrap(), b"OLD"); + let size = getxattr(&target, "com.apple.ResourceFork", &mut [] as &mut [u8]).unwrap(); + assert_eq!(size, fork.len()); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1); + std::fs::remove_dir_all(dir).unwrap(); +} + +#[cfg(target_os = "macos")] +#[test] +fn replacing_a_symlink_does_not_copy_attributes_from_its_referent() { + use rustix::fs::{XattrFlags, getxattr, setxattr}; + let dir = test_dir("xattrs-symlink"); + let outside = test_dir("xattrs-outside").join("private.pdf"); + std::fs::write(&outside, b"PRIVATE").unwrap(); + setxattr(&outside, "com.pdfcraft.test", b"private metadata", XattrFlags::empty()).unwrap(); + let target = dir.join("out.pdf"); + std::os::unix::fs::symlink(&outside, &target).unwrap(); + write_atomically(&target, b"NEW", StagingName::TagThenSuffix).unwrap(); + assert_eq!(std::fs::read(&outside).unwrap(), b"PRIVATE"); + assert_eq!(std::fs::read(&target).unwrap(), b"NEW"); + assert!(!target.symlink_metadata().unwrap().file_type().is_symlink()); + let mut bytes = [0u8; 64]; + assert_eq!(getxattr(&target, "com.pdfcraft.test", &mut bytes[..]).unwrap_err(), rustix::io::Errno::NOATTR); + let len = getxattr(&outside, "com.pdfcraft.test", &mut bytes[..]).unwrap(); + assert_eq!(&bytes[..len], b"private metadata"); + std::fs::remove_dir_all(dir).unwrap(); + std::fs::remove_dir_all(outside.parent().unwrap()).unwrap(); +} + +/// #1790: a replaced file keeps its permissions, and its staging file already has them while +/// it is written; a new file gets the same mode as any other new file. +#[cfg(unix)] +#[test] +fn a_replaced_file_keeps_its_permissions() { + use std::os::unix::fs::PermissionsExt as _; + let dir = test_dir("modes"); + let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777; + let target = dir.join("out.pdf"); + for keep in [0o600, 0o640, 0o664] { + std::fs::write(&target, b"OLD").unwrap(); + std::fs::set_permissions(&target, std::fs::Permissions::from_mode(keep)).unwrap(); + let mut staged = 0; + write_atomically_with(&target, b"NEW", StagingName::SuffixThenTag, [keep as u64], |f| { + staged = f.metadata()?.permissions().mode() & 0o777; + Ok(()) + }) + .unwrap(); + assert_eq!(staged, keep, "the staging file has the target's mode before it is flushed"); + assert_eq!(mode(&target), keep, "the saved file keeps mode {keep:o}"); + assert_eq!(std::fs::read(&target).unwrap(), b"NEW"); + } + let fresh = dir.join("new.pdf"); + write_atomically(&fresh, b"NEW", StagingName::TagThenSuffix).unwrap(); + let plain = dir.join("plain"); + std::fs::write(&plain, b"").unwrap(); + assert_eq!(mode(&fresh), mode(&plain), "a new file is not made private"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A replaced file keeps its group as well as its mode, so group permissions never reach a +/// group that couldn't read the file before (the saver's own group, by default). +#[cfg(target_os = "linux")] +#[test] +fn a_replaced_file_keeps_its_group() { + use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _}; + let dir = test_dir("group"); + let target = dir.join("out.pdf"); + std::fs::write(&target, b"OLD").unwrap(); + let created = std::fs::metadata(&target).unwrap().gid(); + let status = std::fs::read_to_string("/proc/self/status").unwrap(); + let groups = status.lines().find_map(|l| l.strip_prefix("Groups:")).unwrap_or_default(); + let Some(other) = groups.split_whitespace().filter_map(|g| g.parse::().ok()).find(|&g| g != created) else { + eprintln!("skipped: the test needs a second group"); + return; + }; + std::os::unix::fs::chown(&target, None, Some(other)).unwrap(); + std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o640)).unwrap(); + let mut staged = (0, 0); + write_atomically_with(&target, b"NEW", StagingName::SuffixThenTag, [7], |f| { + let m = f.metadata()?; + staged = (m.gid(), m.permissions().mode() & 0o777); + Ok(()) + }) + .unwrap(); + assert_eq!(staged, (other, 0o640), "the staging file has the target's group before it is flushed"); + let m = std::fs::metadata(&target).unwrap(); + assert_eq!((m.gid(), m.permissions().mode() & 0o777), (other, 0o640)); + let _ = std::fs::remove_dir_all(&dir); +} + +/// #2658: a save replaces the file, but the document is the same one: it keeps its creation +/// date where the system lets a program set it. +#[cfg(any(windows, target_os = "macos"))] +#[test] +fn a_replaced_file_keeps_its_creation_date() { + #[cfg(target_os = "macos")] + use std::os::macos::fs::FileTimesExt as _; + #[cfg(windows)] + use std::os::windows::fs::FileTimesExt as _; + let dir = test_dir("created"); + let target = dir.join("doc.pdf"); + std::fs::write(&target, b"OLD").unwrap(); + // 2020-01-02 03:04:05 UTC. + let then = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_577_934_245); + let file = std::fs::OpenOptions::new().write(true).open(&target).unwrap(); + file.set_times(std::fs::FileTimes::new().set_created(then)).unwrap(); + drop(file); + assert_eq!(std::fs::metadata(&target).unwrap().created().unwrap(), then, "the fixture's date is set"); + write_atomically(&target, b"NEW", StagingName::SuffixThenTag).unwrap(); + assert_eq!(std::fs::read(&target).unwrap(), b"NEW"); + assert_eq!(std::fs::metadata(&target).unwrap().created().unwrap(), then, "the saved file keeps its creation date"); + // A new file gets a date of its own. + let fresh = dir.join("new.pdf"); + write_atomically(&fresh, b"NEW", StagingName::SuffixThenTag).unwrap(); + assert!(std::fs::metadata(&fresh).unwrap().created().unwrap() > then); + let _ = std::fs::remove_dir_all(&dir); +} + +/// #4593: a save replaces the file, so a hidden or system file stays hidden or system, and +/// its staging file already is while it is written; a plain file stays plain, though the +/// staging name starts with a dot; a new file gets no attribute from anywhere. +#[cfg(windows)] +#[test] +fn a_replaced_file_keeps_its_hidden_and_system_attributes() { + use std::os::windows::fs::{MetadataExt as _, OpenOptionsExt as _}; + const HIDDEN: u32 = 0x2; + const SYSTEM: u32 = 0x4; + let dir = test_dir("attributes"); + let kept = |p: &Path| std::fs::metadata(p).unwrap().file_attributes() & (HIDDEN | SYSTEM); + for (i, keep) in [0, HIDDEN, SYSTEM, HIDDEN | SYSTEM].into_iter().enumerate() { + let target = dir.join(format!("doc{i}.pdf")); + let mut fixture = std::fs::OpenOptions::new().write(true).create_new(true).attributes(keep).open(&target).unwrap(); + std::io::Write::write_all(&mut fixture, b"OLD").unwrap(); + drop(fixture); + assert_eq!(kept(&target), keep, "the fixture has attributes {keep:#x}"); + for form in [StagingName::TagThenSuffix, StagingName::SuffixThenTag] { + let mut staged = u32::MAX; + write_atomically_with(&target, b"NEW", form, [i as u64], |f| { + staged = f.metadata()?.file_attributes() & (HIDDEN | SYSTEM); + Ok(()) + }) + .unwrap(); + assert_eq!(staged, keep, "the staging file has the target's attributes before it is flushed"); + assert_eq!(kept(&target), keep, "the saved file keeps attributes {keep:#x}"); + assert_eq!(std::fs::read(&target).unwrap(), b"NEW"); + } + } + let fresh = dir.join("new.pdf"); + write_atomically(&fresh, b"NEW", StagingName::TagThenSuffix).unwrap(); + assert_eq!(kept(&fresh), 0, "a new file is neither hidden nor system"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A save replaces a symbolic link, not the file it points to, so the saved file takes the +/// link's own attributes: a plain link to a hidden file saves as a plain file, and the file +/// it pointed to is left as it was. +#[cfg(windows)] +#[test] +fn a_replaced_link_keeps_its_own_attributes_not_its_targets() { + use std::os::windows::fs::{MetadataExt as _, OpenOptionsExt as _}; + const HIDDEN: u32 = 0x2; + let dir = test_dir("link-attributes"); + let referent = dir.join("referent.pdf"); + let mut fixture = std::fs::OpenOptions::new().write(true).create_new(true).attributes(HIDDEN).open(&referent).unwrap(); + std::io::Write::write_all(&mut fixture, b"OLD").unwrap(); + drop(fixture); + let link = dir.join("link.pdf"); + if let Err(e) = std::os::windows::fs::symlink_file(&referent, &link) { + eprintln!("skipped: creating a symbolic link needs Developer Mode or administrator rights ({e})"); + let _ = std::fs::remove_dir_all(&dir); + return; + } + assert_eq!(std::fs::symlink_metadata(&link).unwrap().file_attributes() & HIDDEN, 0, "the link itself is visible"); + write_atomically(&link, b"NEW", StagingName::TagThenSuffix).unwrap(); + let saved = std::fs::symlink_metadata(&link).unwrap(); + assert!(!saved.file_type().is_symlink(), "the save replaced the link"); + assert_eq!(saved.file_attributes() & HIDDEN, 0, "the link was visible, so the saved file is"); + assert_eq!(std::fs::read(&link).unwrap(), b"NEW"); + assert_eq!(std::fs::read(&referent).unwrap(), b"OLD", "the file the link pointed to is untouched"); + assert_ne!(std::fs::metadata(&referent).unwrap().file_attributes() & HIDDEN, 0, "and still hidden"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/preflight/Cargo.toml b/crates/preflight/Cargo.toml index 082e44fc5c..99a72d9b6c 100644 --- a/crates/preflight/Cargo.toml +++ b/crates/preflight/Cargo.toml @@ -14,7 +14,7 @@ workspace = true pdfcraft-content = { workspace = true } pdfcraft-cos = { workspace = true } pdfcraft-model = { workspace = true } -roxmltree = "0.20" +roxmltree = "0.21" [dev-dependencies] flate2 = { workspace = true } diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index 13ca8997e3..1df39ebf05 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -514,97 +514,161 @@ pub fn verify(doc: &Document, level: Level) -> Vec { issue("6.5.2", "Page {} has additional actions", Some((i + 1).to_string()), Some(i), true); } } - for (r, o) in &objs { + check_objects(doc, level, &objs, &page_of, &mut issue); + // One message per kind of problem is enough for repeated ones (as preflight summaries do). + let mut seen = std::collections::HashSet::new(); + out.retain(|i| seen.insert((i.clause, i.message.clone(), i.page))); + out +} + +fn check_objects( + doc: &Document, + level: Level, + objects: &[(ObjRef, std::sync::Arc)], + page_of: &impl Fn(ObjRef) -> Option, + issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool), +) { + for (r, o) in objects { let Some(d) = dict_of(o) else { continue }; - let ty = d.name(b"Type"); - let sub = d.name(b"Subtype"); - // Actions anywhere (structure elements also have /S: their role), with the actions they - // chain to and those written directly in an /A or /OpenAction. - let own = (d.get(b"S").is_some() && is_action(d)).then_some(d); - let direct = [b"A".as_slice(), b"OpenAction"].map(|k| d.get(k).and_then(|a| a.as_dict())); - for a in [own].into_iter().chain(direct).flatten() { - if let Some(s) = chain_forbidden(doc, a) { - issue("6.5.1", "A {} action isn't allowed", Some(s), None, true); - } + check_one_object(doc, level, *r, d, o, page_of, issue); + } +} + +fn check_one_object( + doc: &Document, + level: Level, + reference: ObjRef, + dict: &Dict, + object: &Object, + page_of: &impl Fn(ObjRef) -> Option, + issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool), +) { + let ty = dict.name(b"Type"); + let subtype = dict.name(b"Subtype"); + check_actions(doc, dict, issue); + if is_annotation(dict, ty, subtype) && check_annotation(doc, dict, subtype, page_of(reference), issue) { + // A forbidden annotation stops every later check for its object, as the scan did before + // it was split into helpers: the rest of its own checks and the form field, font, stream + // and embedded file below all came after that `continue`. + return; + } + if dict.get(b"FT").is_some() && dict.get(b"AA").is_some() && ty != Some(b"Annot") { + issue("6.5.2", "A form field has additional actions", None, None, true); + } + if ty == Some(b"Font") || dict.get(b"BaseFont").is_some() && subtype.is_some() { + check_font(doc, dict, issue); + } + if let Object::Stream(stream) = object { + check_stream(doc, stream, subtype, issue); + } + if ty == Some(b"Filespec") && dict.get(b"EF").is_some() { + check_embedded_file(level, dict, issue); + } +} + +fn check_actions(doc: &Document, dict: &Dict, issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool)) { + let own = (dict.get(b"S").is_some() && is_action(dict)).then_some(dict); + let direct = [b"A".as_slice(), b"OpenAction"].map(|key| dict.get(key).and_then(|action| action.as_dict())); + for action in [own].into_iter().chain(direct).flatten() { + if let Some(name) = chain_forbidden(doc, action) { + issue("6.5.1", "A {} action isn't allowed", Some(name), None, true); } - // Annotations. - if ty == Some(b"Annot") || (sub.is_some() && d.get(b"Rect").is_some() && d.get(b"P").is_some()) { - let page = page_of(*r); - let name = sub.map(|s| String::from_utf8_lossy(s).into_owned()).unwrap_or_default(); - if sub.is_some_and(|s| FORBIDDEN_ANNOTS.contains(&s)) { - issue("6.3.1", "{} annotations aren't allowed", Some(name), page, true); - continue; - } - let f = d.int(b"F").unwrap_or(0); - if sub != Some(b"Popup") && (f & 4 == 0 || f & (1 | 2 | 32) != 0) { - issue("6.3.2", "A {} annotation isn't set to print, or is hidden", Some(name.clone()), page, true); - } - let has_ap = d.get(b"AP").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()).is_some_and(|a| a.get(b"N").is_some()); - if !has_ap && !matches!(sub, Some(b"Popup") | Some(b"Link")) { - let zero = d.get(b"Rect").map(|x| doc.resolve(x)).and_then(|x| x.as_array().cloned()).is_some_and(|a| { - let v: Vec = a.iter().filter_map(|n| doc.resolve(n).as_f64()).collect(); - v.len() == 4 && (v[2] - v[0]).abs() < 1e-6 && (v[3] - v[1]).abs() < 1e-6 - }); - if !zero { - issue("6.3.3", "A {} annotation has no appearance stream", Some(name.clone()), page, false); - } - } - if d.get(b"AA").is_some() { - issue("6.5.2", "A {} annotation has additional actions", Some(name), page, true); - } + } +} + +fn is_annotation(dict: &Dict, ty: Option<&[u8]>, subtype: Option<&[u8]>) -> bool { + ty == Some(b"Annot") || (subtype.is_some() && dict.get(b"Rect").is_some() && dict.get(b"P").is_some()) +} + +fn check_annotation( + doc: &Document, + dict: &Dict, + subtype: Option<&[u8]>, + page: Option, + issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool), +) -> bool { + let name = subtype.map(|bytes| String::from_utf8_lossy(bytes).into_owned()).unwrap_or_default(); + if subtype.is_some_and(|value| FORBIDDEN_ANNOTS.contains(&value)) { + issue("6.3.1", "{} annotations aren't allowed", Some(name), page, true); + return true; + } + let flags = dict.int(b"F").unwrap_or(0); + if subtype != Some(b"Popup") && (flags & 4 == 0 || flags & (1 | 2 | 32) != 0) { + issue("6.3.2", "A {} annotation isn't set to print, or is hidden", Some(name.clone()), page, true); + } + check_annotation_appearance(doc, dict, subtype, &name, page, issue); + if dict.get(b"AA").is_some() { + issue("6.5.2", "A {} annotation has additional actions", Some(name), page, true); + } + false +} + +fn check_annotation_appearance( + doc: &Document, + dict: &Dict, + subtype: Option<&[u8]>, + name: &str, + page: Option, + issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool), +) { + let has_appearance = dict + .get(b"AP") + .map(|appearance| doc.resolve(appearance)) + .and_then(|appearance| appearance.as_dict().cloned()) + .is_some_and(|appearance| appearance.get(b"N").is_some()); + if has_appearance || matches!(subtype, Some(b"Popup") | Some(b"Link")) { + return; + } + let zero_area = dict.get(b"Rect").map(|rect| doc.resolve(rect)).and_then(|rect| rect.as_array().cloned()).is_some_and(|values| { + let values: Vec = values.iter().filter_map(|value| doc.resolve(value).as_f64()).collect(); + values.len() == 4 && (values[2] - values[0]).abs() < 1e-6 && (values[3] - values[1]).abs() < 1e-6 + }); + if !zero_area { + issue("6.3.3", "A {} annotation has no appearance stream", Some(name.to_owned()), page, false); + } +} + +fn check_stream( + doc: &Document, + stream: &pdfcraft_cos::Stream, + subtype: Option<&[u8]>, + issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool), +) { + let filters: Vec> = match stream.dict.get(b"Filter").map(|filter| doc.resolve(filter)) { + Some(filter) => match &*filter { + Object::Name(name) => vec![name.clone()], + Object::Array(filters) => filters.iter().filter_map(|filter| filter.as_name().map(<[u8]>::to_vec)).collect(), + _ => Vec::new(), + }, + None => Vec::new(), + }; + if filters.iter().any(|filter| filter == b"LZWDecode") { + issue("6.1.7.2", "A stream uses LZW compression", None, None, false); + } + if stream.dict.get(b"F").is_some() || stream.dict.get(b"FFilter").is_some() { + issue("6.1.7.1", "A stream refers to an external file", None, None, false); + } + if subtype == Some(b"Image") { + if matches!(stream.dict.get(b"Interpolate"), Some(Object::Bool(true))) { + issue("6.2.8", "An image asks to be interpolated", None, None, true); } - // Form fields with additional actions (non-widget fields). - if d.get(b"FT").is_some() && d.get(b"AA").is_some() && ty != Some(b"Annot") { - issue("6.5.2", "A form field has additional actions", None, None, true); - } - // Fonts. - if ty == Some(b"Font") || d.get(b"BaseFont").is_some() && sub.is_some() { - check_font(doc, d, &mut issue); - } - // Streams. - if let Object::Stream(s) = &**o { - let filters: Vec> = match s.dict.get(b"Filter").map(|f| doc.resolve(f)) { - Some(f) => match &*f { - Object::Name(n) => vec![n.clone()], - Object::Array(a) => a.iter().filter_map(|x| x.as_name().map(<[u8]>::to_vec)).collect(), - _ => Vec::new(), - }, - None => Vec::new(), - }; - if filters.iter().any(|f| f == b"LZWDecode") { - issue("6.1.7.2", "A stream uses LZW compression", None, None, false); - } - if s.dict.get(b"F").is_some() || s.dict.get(b"FFilter").is_some() { - issue("6.1.7.1", "A stream refers to an external file", None, None, false); - } - if sub == Some(b"Image") { - if matches!(s.dict.get(b"Interpolate"), Some(Object::Bool(true))) { - issue("6.2.8", "An image asks to be interpolated", None, None, true); - } - if s.dict.get(b"Alternates").is_some() || s.dict.get(b"OPI").is_some() { - issue("6.2.8", "An image has alternates or OPI information", None, None, true); - } - } - if sub == Some(b"PS") { - issue("6.2.9", "PostScript XObjects aren't allowed", None, None, false); - } + if stream.dict.get(b"Alternates").is_some() || stream.dict.get(b"OPI").is_some() { + issue("6.2.8", "An image has alternates or OPI information", None, None, true); } - // Embedded files. - if ty == Some(b"Filespec") && d.get(b"EF").is_some() { - match level { - Level::A2b => issue("6.8", "Embedded files must be PDF/A files themselves (use PDF/A-3 for others)", None, None, false), - Level::A3b => { - if d.get(b"AFRelationship").is_none() { - issue("6.8", "An embedded file has no AFRelationship", None, None, true); - } - } - } + } + if subtype == Some(b"PS") { + issue("6.2.9", "PostScript XObjects aren't allowed", None, None, false); + } +} + +fn check_embedded_file(level: Level, dict: &Dict, issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool)) { + match level { + Level::A2b => issue("6.8", "Embedded files must be PDF/A files themselves (use PDF/A-3 for others)", None, None, false), + Level::A3b if dict.get(b"AFRelationship").is_none() => { + issue("6.8", "An embedded file has no AFRelationship", None, None, true); } + Level::A3b => {} } - // One message per kind of problem is enough for repeated ones (as preflight summaries do). - let mut seen = std::collections::HashSet::new(); - out.retain(|i| seen.insert((i.clause, i.message.clone(), i.page))); - out } fn check_font(doc: &Document, d: &Dict, issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool)) { diff --git a/crates/preflight/src/xmp.rs b/crates/preflight/src/xmp.rs index 0aaab12b23..7a54d70a1b 100644 --- a/crates/preflight/src/xmp.rs +++ b/crates/preflight/src/xmp.rs @@ -34,7 +34,7 @@ fn parsed_identifier(xmp: &str, namespace: &str, local: &str) -> Result = &'a dyn Fn(&str) -> String; + +/// The [`Words`] that translate nothing: the wording as it is written here. +pub fn in_english(wording: &str) -> String { + wording.to_owned() +} + +/// What a person can get wrong about what to print, in English, with their holes still in them. +/// +/// An interface that reports these in another language translates these three, which is the whole +/// reason they are gathered here; `wordings_are_said` keeps them in step with the errors. +pub const WORDINGS: &[&str] = &["there are no pages to print", "page {n} is out of range (1–{count})", "{page} is not a page number or label"]; + +impl PrintError { + /// This error as one sentence, said in the language `words` translates into. + /// + /// The wording is translated and the numbers go in afterwards, in a single pass, so what the + /// person typed cannot be read as a hole. With [`in_english`] this is the error's own + /// `Display`, which is what the command line and scripts report. + pub fn said(&self, words: Words) -> String { + match self { + PrintError::NoPages => words("there are no pages to print"), + PrintError::OutOfRange(n, count) => { + say(&words("page {n} is out of range (1–{count})"), &[("{n}", n.to_string()), ("{count}", count.to_string())]) + } + // The token goes in quoted, the way it is typed, as the error itself prints it. + PrintError::NotAPage(token) => say(&words("{page} is not a page number or label"), &[("{page}", format!("{token:?}"))]), + fault => fault.to_string(), + } + } +} + +/// A wording with its holes filled. +/// +/// A hole is a name in braces, such as `{n}`; what is not in `values` is left as it is written. +/// Values go in during a single pass, so a value with braces in it is never read as a hole. +fn say(wording: &str, values: &[(&str, String)]) -> String { + let mut out = String::with_capacity(wording.len() + 8); + let mut rest = wording; + while let Some(open) = rest.find('{') { + let (before, from_brace) = rest.split_at(open); + out.push_str(before); + // `find` counts from the brace and `}` is one byte, so this is a char boundary. + let Some(close) = from_brace.find('}') else { + // A brace with nothing closing it is just text. + out.push_str(from_brace); + return out; + }; + let Some(hole) = from_brace.get(..=close) else { return out + from_brace }; + match values.iter().find(|(name, _)| *name == hole) { + Some((_, value)) => out.push_str(value), + None => out.push_str(hole), + } + rest = from_brace.get(close + 1..).unwrap_or_default(); + } + out.push_str(rest); + out +} diff --git a/crates/print/src/imposition.rs b/crates/print/src/imposition.rs new file mode 100644 index 0000000000..348ae151ba --- /dev/null +++ b/crates/print/src/imposition.rs @@ -0,0 +1,349 @@ +use super::error::PrintError; +use super::settings::{Content, Settings}; +use super::sheets::{Sheet, layout}; +use super::{DecodeBudget, Dict, Document, Matrix, ObjRef, Object, SaveOptions, Stream, write_full}; + +// Model-derived transforms and clipping bounds need their full PDF decimal precision. +fn geometry_number(value: f64) -> String { + let mut bytes = Vec::new(); + pdfcraft_cos::serialize(&Object::Real(value), &mut bytes); + String::from_utf8_lossy(&bytes).into_owned() +} + +fn n(v: f64) -> String { + let s = format!("{v:.4}"); + let s = s.trim_end_matches('0').trim_end_matches('.'); + if s == "-0" { "0".into() } else { s.into() } +} + +/// The page's content streams decoded against `budget` and joined with a space as the renderer +/// joins them; streams that don't decode are left out. `None` when the budget ran out, so part of +/// the content is missing. +pub(crate) fn decoded_contents(doc: &Document, page: &Dict, budget: &mut DecodeBudget) -> Option> { + let Some(contents) = page.get(b"Contents") else { return Some(Vec::new()) }; + let mut out = Vec::new(); + for data in budget.decode_contents(doc, contents) { + match data { + Ok(data) => { + out.extend_from_slice(&data); + out.push(b' '); + } + Err(_) if budget.remaining() == 0 => return None, + Err(_) => {} + } + } + Some(out) +} + +/// Reduce `doc`'s pages to what [`impose`] draws of them under `content`: the annotations that +/// don't print are dropped, and with [`Content::FormFieldsOnly`] the page content too. For +/// printing pages as pictures, where a renderer draws each page as it stands. +pub fn keep_printed(doc: &mut Document, content: Content) { + for page in pdfcraft_model::pages(doc) { + let annots = page.dict.get(b"Annots").and_then(|a| doc.resolve(a).as_array().cloned()).unwrap_or_default(); + let kept: Vec = annots.into_iter().filter(|a| doc.resolve(a).as_dict().is_some_and(|d| prints(doc, d, content))).collect(); + // Fails only when the page object isn't a dictionary, and such a page draws nothing. + let _ = doc.update_dict(page.obj, |d| { + if content == Content::FormFieldsOnly { + d.remove(b"Contents"); + } + if d.contains(b"Annots") { + d.set(b"Annots".to_vec(), Object::Array(kept)); + } + }); + } +} + +/// The standard annotation types of ISO 32000-2 Table 171. +const STANDARD_ANNOTATION_TYPES: [&[u8]; 28] = [ + b"Text", + b"Link", + b"FreeText", + b"Line", + b"Square", + b"Circle", + b"Polygon", + b"PolyLine", + b"Highlight", + b"Underline", + b"Squiggly", + b"StrikeOut", + b"Caret", + b"Stamp", + b"Ink", + b"Popup", + b"FileAttachment", + b"Sound", + b"Movie", + b"Screen", + b"Widget", + b"PrinterMark", + b"TrapNet", + b"Watermark", + b"3D", + b"Redact", + b"Projection", + b"RichMedia", +]; + +/// Does annotation `d` print under `content`? +pub(crate) fn prints(doc: &Document, d: &Dict, content: Content) -> bool { + const INVISIBLE: i64 = 1; + const HIDDEN: i64 = 2; + const PRINT: i64 = 4; + let flags = d.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0); + if flags & PRINT == 0 || flags & HIDDEN != 0 { + return false; + } + // `/Subtype` may be an indirect reference (ISO 32000-2 §7.3.10), so resolve it first. + let subtype_obj = d.get(b"Subtype").map(|s| doc.resolve(s)); + let subtype = subtype_obj.as_deref().and_then(Object::as_name).unwrap_or(b""); + match subtype { + b"Widget" => true, + b"Link" | b"Popup" => false, + b"Stamp" => content != Content::Document && content != Content::FormFieldsOnly, + // ISO 32000-2 Table 167: an Invisible annotation of a type that isn't standard (Table + // 171) is not printed even with its Print flag set. + _ if flags & INVISIBLE != 0 && !STANDARD_ANNOTATION_TYPES.contains(&subtype) => false, + _ => content == Content::DocumentAndMarkups, + } +} + +/// The normal appearance stream to draw for an annotation (honouring `/AS`). +pub(crate) fn appearance(doc: &Document, d: &Dict) -> Option { + let ap = doc.resolve(d.get(b"AP")?); + let normal = ap.as_dict()?.get(b"N")?.clone(); + match &*doc.resolve(&normal) { + Object::Stream(_) => normal.as_ref(), + Object::Dict(states) => { + let state = d.name(b"AS")?; + states.get(state)?.as_ref() + } + _ => None, + } +} + +/// Algorithm 8.1: the matrix placing an appearance form (BBox × Matrix) onto the annotation rect. +pub(crate) fn appearance_matrix(doc: &Document, form: &Dict, rect: [f64; 4]) -> Option { + let nums = + |k: &[u8]| -> Option> { Some(doc.resolve(form.get(k)?).as_array()?.iter().filter_map(|x| doc.resolve(x).as_f64()).collect()) }; + let bbox = nums(b"BBox").filter(|b| b.len() == 4)?; + let m = nums(b"Matrix").filter(|m| m.len() == 6).map(|m| Matrix([m[0], m[1], m[2], m[3], m[4], m[5]])).unwrap_or_default(); + let b = m.bbox([bbox[0], bbox[1], bbox[2], bbox[3]]); + let (bw, bh) = (b[2] - b[0], b[3] - b[1]); + if bw <= 0.0 || bh <= 0.0 { + return None; + } + let a = Matrix([ + (rect[2] - rect[0]) / bw, + 0.0, + 0.0, + (rect[3] - rect[1]) / bh, + rect[0] - b[0] * (rect[2] - rect[0]) / bw, + rect[1] - b[1] * (rect[3] - rect[1]) / bh, + ]); + // The Do that draws the form applies its /Matrix itself, so only the placement goes here. + Some(a) +} + +/// A Form XObject holding page `index` as it prints (user space; BBox = crop box). +/// `pages` are the document's pages, listed once by the caller: listing them for each page +/// printed would make printing quadratic in the page count. +pub(crate) fn page_form( + doc: &mut Document, + pages: &[pdfcraft_model::Page], + index: usize, + content: Content, + promoted: &mut std::collections::HashMap, +) -> Result { + let page = pages.get(index).ok_or_else(|| PrintError::Invalid(format!("there is no page {}", index.saturating_add(1))))?; + let crop = page.crop(doc); + let mut res = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); + let mut body = if content == Content::FormFieldsOnly { + Vec::new() + } else { + decoded_contents(doc, &page.dict, &mut DecodeBudget::page()) + .ok_or_else(|| PrintError::Invalid(format!("page {} has more content than can be printed", index + 1)))? + }; + // Wrap the page content so a state it leaves behind can't affect the annotations. + if !body.is_empty() { + let mut wrapped = b"q\n".to_vec(); + wrapped.extend_from_slice(&body); + wrapped.extend_from_slice(b"\nQ\n"); + body = wrapped; + } + let annots = page.dict.get(b"Annots").and_then(|a| doc.resolve(a).as_array().cloned()).unwrap_or_default(); + let mut xobjects = res.get(b"XObject").and_then(|x| doc.resolve(x).as_dict().cloned()).unwrap_or_default(); + let mut k = 0usize; + for a in annots { + let Some(d) = doc.resolve(&a).as_dict().cloned() else { continue }; + if !prints(doc, &d, content) { + continue; + } + let rect: Vec = + d.get(b"Rect").and_then(|r| doc.resolve(r).as_array().map(|a| a.iter().filter_map(Object::as_f64).collect())).unwrap_or_default(); + if rect.len() != 4 { + continue; + } + let rect = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])]; + let Some(ap) = appearance(doc, &d) else { continue }; + let form = doc.get(ap).as_dict().cloned().unwrap_or_default(); + let Some(m) = appearance_matrix(doc, &form, rect) else { continue }; + let ap = if !form.contains(b"Subtype") + && let Object::Stream(stream) = &*doc.get(ap) + { + // Some existing annotation appearances omit Subtype, but Do dispatch needs a Form XObject. + // Keep the original intact: other document objects can share this appearance. + if let Some(&cached) = promoted.get(&ap) { + cached + } else { + let mut stream = stream.clone(); + stream.dict.set(b"Subtype".to_vec(), Object::name("Form")); + let copy = doc.add(Object::Stream(stream)); + promoted.insert(ap, copy); + copy + } + } else { + ap + }; + // Keep the page's own XObjects, including names previous print operations generated. + // Check the growing map so each appearance also keeps the name just assigned to it. + let name = loop { + let name = format!("PCPrA{k}"); + k = k.checked_add(1).ok_or_else(|| PrintError::Invalid("too many annotation appearance resources".into()))?; + if !xobjects.contains(name.as_bytes()) { + break name; + } + }; + let [a0, b0, c0, d0, e0, f0] = m.0; + body.extend(format!("q {} {} {} {} {} {} cm /{name} Do Q\n", n(a0), n(b0), n(c0), n(d0), n(e0), n(f0)).bytes()); + xobjects.set(name.into_bytes(), Object::Ref(ap)); + } + if !xobjects.is_empty() { + res.set(b"XObject".to_vec(), Object::Dict(xobjects)); + } + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Form")); + d.set(b"BBox".to_vec(), Object::Array(crop.iter().map(|v| Object::Real(*v)).collect())); + d.set(b"Resources".to_vec(), Object::Dict(res)); + if let Some(g) = page.dict.get(b"Group") { + d.set(b"Group".to_vec(), g.clone()); + } + Ok(doc.add(Object::Stream(Stream::flate(d, &body)))) +} + +#[cfg(test)] +thread_local! { + /// Pages listed by [`source_pages`] on this thread. + pub(crate) static PAGES_LISTED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// The source document's pages, for printing. +fn source_pages(doc: &Document) -> Vec { + let pages = pdfcraft_model::pages(doc); + #[cfg(test)] + PAGES_LISTED.with(|c| c.set(c.get() + pages.len())); + pages +} + +/// Build the print-ready PDF for `settings`. Encryption is not carried over (the file goes to a +/// printer or is the user's own copy); callers must check the print permission first. +pub fn impose(src: &Document, settings: &Settings) -> Result, PrintError> { + let pages = source_pages(src); + let sizes: Vec<(f64, f64)> = pages.iter().map(|p| p.display_size(src)).collect(); + let sheets = layout(&sizes, settings)?; + let mut doc = src.clone(); + // One form per source page used (a page may appear on several sheets: posters). + let mut forms: std::collections::HashMap = std::collections::HashMap::new(); + let mut promoted = std::collections::HashMap::new(); + let views: Vec<[f64; 6]> = pages.iter().map(|p| p.view_matrix(src)).collect(); + let pages_root = doc.add(Object::Null); + let mut kids = Vec::new(); + for sheet in &sheets { + let mut c = String::new(); + let mut xo = Dict::new(); + for (i, pl) in sheet.placed.iter().enumerate() { + let form = match forms.get(&pl.page) { + Some(f) => *f, + None => { + let f = page_form(&mut doc, &pages, pl.page, settings.content, &mut promoted)?; + forms.insert(pl.page, f); + f + } + }; + let name = format!("P{i}"); + xo.set(name.clone().into_bytes(), Object::Ref(form)); + // Display space → sheet, then clip to the visible part, then user → display. + let [a, b, cc, d, e, f] = pl.matrix.0; + let to_display = Matrix(views[pl.page]).invert().unwrap_or_default(); + let [ua, ub, uc, ud, ue, uf] = to_display.0; + let [x0, y0, x1, y1] = pl.clip; + c.push_str(&format!( + "q {} {} {} {} {} {} cm {} {} {} {} re W n {} {} {} {} {} {} cm /{name} Do Q\n", + geometry_number(a), + geometry_number(b), + geometry_number(cc), + geometry_number(d), + geometry_number(e), + geometry_number(f), + geometry_number(x0), + geometry_number(y0), + geometry_number(x1 - x0), + geometry_number(y1 - y0), + geometry_number(ua), + geometry_number(ub), + geometry_number(uc), + geometry_number(ud), + geometry_number(ue), + geometry_number(uf) + )); + } + if !sheet.borders.is_empty() || !sheet.lines.is_empty() { + c.push_str("q 0 G 0.5 w\n"); + for r in &sheet.borders { + c.push_str(&format!("{} {} {} {} re S\n", n(r[0]), n(r[1]), n(r[2] - r[0]), n(r[3] - r[1]))); + } + for l in &sheet.lines { + c.push_str(&format!("{} {} m {} {} l S\n", n(l[0]), n(l[1]), n(l[2]), n(l[3]))); + } + c.push_str("Q\n"); + } + let contents = doc.add(Object::Stream(Stream::flate(Dict::new(), c.as_bytes()))); + let mut res = Dict::new(); + res.set(b"XObject".to_vec(), Object::Dict(xo)); + let mut p = Dict::new(); + p.set(b"Type".to_vec(), Object::name("Page")); + p.set(b"Parent".to_vec(), Object::Ref(pages_root)); + p.set(b"MediaBox".to_vec(), Object::Array([0.0, 0.0, sheet.size.0, sheet.size.1].iter().map(|v| Object::Real(*v)).collect())); + p.set(b"Resources".to_vec(), Object::Dict(res)); + p.set(b"Contents".to_vec(), Object::Ref(contents)); + kids.push(Object::Ref(doc.add(Object::Dict(p)))); + } + let mut tree = Dict::new(); + tree.set(b"Type".to_vec(), Object::name("Pages")); + tree.set(b"Count".to_vec(), Object::Int(kids.len() as i64)); + tree.set(b"Kids".to_vec(), Object::Array(kids)); + doc.set(pages_root, Object::Dict(tree)); + let mut cat = Dict::new(); + cat.set(b"Type".to_vec(), Object::name("Catalog")); + cat.set(b"Pages".to_vec(), Object::Ref(pages_root)); + // Keep the layers' configuration, or a layer that is off in the document would print: the + // pages' marked content still refers to its groups, and without this nothing says they are off. + let layers = src.trailer().get(b"Root").and_then(|r| src.resolve(r).as_dict().and_then(|root| root.get(b"OCProperties").cloned())); + if let Some(layers) = layers { + cat.set(b"OCProperties".to_vec(), layers); + } + let root = doc.add(Object::Dict(cat)); + doc.trailer_mut().set(b"Root".to_vec(), Object::Ref(root)); + doc.trailer_mut().remove(b"Info"); + if doc.trailer().contains(b"Encrypt") { + doc.remove_encryption(); + } + Ok(write_full(&doc, &SaveOptions::default())?) +} + +/// Which source pages land on each sheet (for previews and the sheet count). +pub fn sheet_pages(sheets: &[Sheet]) -> Vec> { + sheets.iter().map(|s| s.placed.iter().map(|p| p.page).collect()).collect() +} diff --git a/crates/print/src/lib.rs b/crates/print/src/lib.rs index b731aff00f..067112b2e7 100644 --- a/crates/print/src/lib.rs +++ b/crates/print/src/lib.rs @@ -27,1026 +27,19 @@ mod tests; pub use range::{Subset, select_listed, select_pages}; -/// What can go wrong when printing. -/// -/// The three things a *person* can be wrong about — there is nothing to print, a page number past -/// the end, a range that is not a page — each have their own variant with the numbers apart from -/// the words, so an interface can say them in its own language with [`PrintError::said`]. The rest -/// are faults rather than mistakes and carry their text as it is. -#[derive(Debug, thiserror::Error, PartialEq)] -pub enum PrintError { - #[error("there are no pages to print")] - NoPages, - /// A page number past the end of the document: the number asked for, and the page count. - #[error("page {0} is out of range (1–{1})")] - OutOfRange(usize, usize), - /// A range token that is neither a page number nor a page label. - #[error("{0:?} is not a page number or label")] - NotAPage(String), - #[error("{0}")] - Invalid(String), - #[error("{0}")] - Spool(String), - #[error(transparent)] - Cos(#[from] pdfcraft_cos::CosError), -} - -/// How an interface says one of our wordings in its own language, holes and all: given the English -/// in [`WORDINGS`], the same wording in that language. [`in_english`] keeps the English. -pub type Words<'a> = &'a dyn Fn(&str) -> String; - -/// The [`Words`] that translate nothing: the wording as it is written here. -pub fn in_english(wording: &str) -> String { - wording.to_owned() -} - -/// What a person can get wrong about what to print, in English, with their holes still in them. -/// -/// An interface that reports these in another language translates these three, which is the whole -/// reason they are gathered here; `wordings_are_said` keeps them in step with the errors. -pub const WORDINGS: &[&str] = &["there are no pages to print", "page {n} is out of range (1–{count})", "{page} is not a page number or label"]; - -impl PrintError { - /// This error as one sentence, said in the language `words` translates into. - /// - /// The wording is translated and the numbers go in afterwards, in a single pass, so what the - /// person typed cannot be read as a hole. With [`in_english`] this is the error's own - /// `Display`, which is what the command line and scripts report. - pub fn said(&self, words: Words) -> String { - match self { - PrintError::NoPages => words("there are no pages to print"), - PrintError::OutOfRange(n, count) => { - say(&words("page {n} is out of range (1–{count})"), &[("{n}", n.to_string()), ("{count}", count.to_string())]) - } - // The token goes in quoted, the way it is typed, as the error itself prints it. - PrintError::NotAPage(token) => say(&words("{page} is not a page number or label"), &[("{page}", format!("{token:?}"))]), - fault => fault.to_string(), - } - } -} - -/// A wording with its holes filled. -/// -/// A hole is a name in braces, such as `{n}`; what is not in `values` is left as it is written. -/// Values go in during a single pass, so a value with braces in it is never read as a hole. -fn say(wording: &str, values: &[(&str, String)]) -> String { - let mut out = String::with_capacity(wording.len() + 8); - let mut rest = wording; - while let Some(open) = rest.find('{') { - let (before, from_brace) = rest.split_at(open); - out.push_str(before); - // `find` counts from the brace and `}` is one byte, so this is a char boundary. - let Some(close) = from_brace.find('}') else { - // A brace with nothing closing it is just text. - out.push_str(from_brace); - return out; - }; - let Some(hole) = from_brace.get(..=close) else { return out + from_brace }; - match values.iter().find(|(name, _)| *name == hole) { - Some((_, value)) => out.push_str(value), - None => out.push_str(hole), - } - rest = from_brace.get(close + 1..).unwrap_or_default(); - } - out.push_str(rest); - out -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] -pub enum Orientation { - #[default] - Auto, - Portrait, - Landscape, -} - -#[derive(Clone, Copy, Debug, PartialEq, Default)] -pub enum SizeMode { - #[default] - Fit, - Actual, - Shrink, - /// Percent. - Custom(f64), -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] -pub enum PageOrder { - #[default] - Horizontal, - HorizontalReversed, - Vertical, - VerticalReversed, - /// Single-sided sheets: cut into cell piles, then stack them in row-major order. - CutStack, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] -pub enum BookletSubset { - #[default] - BothSides, - FrontOnly, - BackOnly, -} - -/// How a booklet goes through the printer (#2707): it decides two-sided printing itself, as -/// Acrobat does (its Print dialog hides Two-sided while Booklet is chosen; Using Acrobat X Pro, -/// Print dialog box options: "Acrobat automatically enables duplex printing, if available, for -/// booklet printing"). Both sides flip on the short edge, the fold of a 2-up sheet turned -/// sideways; front side only or back side only is one pass of a manual duplex and prints -/// single-sided. -pub fn booklet_duplex(subset: BookletSubset) -> spool::Duplex { - match subset { - BookletSubset::BothSides => spool::Duplex::ShortEdge, - BookletSubset::FrontOnly | BookletSubset::BackOnly => spool::Duplex::Off, - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] -pub enum Binding { - #[default] - Left, - Right, -} - -#[derive(Clone, Copy, Debug, PartialEq)] -pub enum Layout { - Size(SizeMode), - Multiple { - cols: usize, - rows: usize, - /// Source pages on each sheet, at most `cols × rows`: three per sheet uses a 2 × 2 grid - /// with one cell left blank. - per_sheet: usize, - order: PageOrder, - border: bool, - auto_rotate: bool, - }, - Booklet { - subset: BookletSubset, - binding: Binding, - }, - /// Tile scale (percent), overlap (points), cut marks. - Poster { - scale: f64, - overlap: f64, - cut_marks: bool, - }, - /// Shipping label: each page cut to its [`Settings::crops`] box, turned so its text reads - /// upright on the sheet and fitted with [`LABEL_MARGIN`]; `flip` turns it a further 180°. - Label { - flip: bool, - }, -} - -impl Default for Layout { - fn default() -> Self { - Layout::Size(SizeMode::Fit) - } -} - -impl Layout { - /// Acrobat's pages-per-sheet choices as a grid (columns × rows on a portrait sheet). - pub fn multiple(per_sheet: usize) -> Layout { - let (cols, rows) = match per_sheet { - 2 => (1, 2), - 4 => (2, 2), - 6 => (2, 3), - 9 => (3, 3), - 16 => (4, 4), - n => { - let c = (n as f64).sqrt().ceil().max(1.0) as usize; - (c, n.div_ceil(c).max(1)) - } - }; - Layout::Multiple { cols, rows, per_sheet: per_sheet.max(1), order: PageOrder::Horizontal, border: false, auto_rotate: true } - } -} - -/// Acrobat's Comments & Forms choices. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] -pub enum Content { - /// Page content and form fields. - Document, - /// Page content, form fields and every printable comment. - #[default] - DocumentAndMarkups, - /// Page content, form fields and stamps. - DocumentAndStamps, - /// Form fields only. - FormFieldsOnly, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct Settings { - /// Source pages (0-based) in print order; see [`select_pages`]. - pub pages: Vec, - /// Paper (width, height) in points, portrait. - pub paper: (f64, f64), - pub orientation: Orientation, - pub layout: Layout, - pub content: Content, - /// For [`Layout::Label`], per source page: the area to keep (display space) and whether its - /// text runs bottom-to-top (so a quarter turn clockwise makes it upright). `None` or a - /// missing entry keeps the whole page. - pub crops: Vec>, - /// Print as image (Acrobat's Advanced Print Setup): every page rendered to a picture at this - /// resolution before it is placed on the paper. The engine does the rendering; `impose` is - /// then given the image-only document. - pub raster: Option, -} - -/// How pages are rendered when printing as image. -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct Raster { - /// Dots per inch, 72–600 (Acrobat offers 150, 300 and 600). - pub dpi: f64, - /// Render the pictures in grey, so the print-ready PDF itself is grayscale. - pub grayscale: bool, -} - -impl Default for Raster { - fn default() -> Self { - Raster { dpi: 300.0, grayscale: false } - } -} - -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct Crop { - pub rect: [f64; 4], - pub text_up: bool, -} - -impl Default for Settings { - fn default() -> Self { - Settings { - pages: Vec::new(), - paper: PAPERS[0].1, - orientation: Orientation::Auto, - layout: Layout::default(), - content: Content::default(), - raster: None, - crops: Vec::new(), - } - } -} - -/// Common paper sizes (points, portrait). -pub const PAPERS: [(&str, (f64, f64)); 21] = [ - ("US Letter", (612.0, 792.0)), - ("US Legal", (612.0, 1008.0)), - ("Tabloid", (792.0, 1224.0)), - ("A3", (841.89, 1190.55)), - ("A4", (595.28, 841.89)), - ("A5", (419.53, 595.28)), - ("A6", (297.64, 419.53)), - ("ARCH A (9 x 12 in)", (648.0, 864.0)), - ("ARCH B (12 x 18 in)", (864.0, 1296.0)), - ("ARCH C (18 x 24 in)", (1296.0, 1728.0)), - ("ARCH D (24 x 36 in)", (1728.0, 2592.0)), - ("ARCH E1 (30 x 42 in)", (2160.0, 3024.0)), - ("ARCH E (36 x 48 in)", (2592.0, 3456.0)), - ("ANSI C (17 x 22 in)", (1224.0, 1584.0)), - ("ANSI D (22 x 34 in)", (1584.0, 2448.0)), - ("ANSI E (34 x 44 in)", (2448.0, 3168.0)), - ("A2", (1190.55, 1683.78)), - ("A1", (1683.78, 2383.94)), - ("A0", (2383.94, 3370.39)), - ("4 x 6 in label (101.6 x 152.4 mm)", (288.0, 432.0)), - ("US #10 Envelope (4.125 x 9.5 in)", (297.0, 684.0)), -]; - -/// `PAPERS` index of the 4 × 6 in shipping label. -pub const LABEL_PAPER: usize = 19; - -/// The CUPS media name for `PAPERS[index]`, so the spooler feeds the sheet the PDF was laid out -/// on rather than the queue's default; `None` only past the end of `PAPERS`. The common sizes -/// use their PPD keywords (`w288h432` is the usual 4×6 label, `Env10` the US #10 envelope). -/// The rest use PWG 5101.1 names (ARCH E1 is its `wide-format` size), spelled exactly as the -/// spec and the IANA registry have them: a driverless printer may take only the names it -/// advertises. They carry their dimensions, so CUPS feeds the PPD size of those dimensions -/// whatever the driver calls it (`ARCHD`, `AnsiD`) (#3752). -pub fn cups_media(index: usize) -> Option<&'static str> { - Some(match PAPERS.get(index)?.0 { - "US Letter" => "Letter", - "US Legal" => "Legal", - "Tabloid" => "Tabloid", - "A3" => "A3", - "A4" => "A4", - "A5" => "A5", - "A6" => "iso_a6_105x148mm", - "ARCH A (9 x 12 in)" => "na_arch-a_9x12in", - "ARCH B (12 x 18 in)" => "na_arch-b_12x18in", - "ARCH C (18 x 24 in)" => "na_arch-c_18x24in", - "ARCH D (24 x 36 in)" => "na_arch-d_24x36in", - "ARCH E1 (30 x 42 in)" => "na_wide-format_30x42in", - "ARCH E (36 x 48 in)" => "na_arch-e_36x48in", - "ANSI C (17 x 22 in)" => "na_c_17x22in", - "ANSI D (22 x 34 in)" => "na_d_22x34in", - "ANSI E (34 x 44 in)" => "na_e_34x44in", - "A2" => "iso_a2_420x594mm", - "A1" => "iso_a1_594x841mm", - "A0" => "iso_a0_841x1189mm", - "US #10 Envelope (4.125 x 9.5 in)" => "Env10", - _ if index == LABEL_PAPER => "w288h432", - // A size added to PAPERS without a name here: its tests fail, and meanwhile the queue's - // paper stands, as it did for every size before #1583. - _ => return None, - }) -} - -/// The job option that feeds `PAPERS[index]` (`media`, see [`cups_media`]), for the Print -/// dialog's jobs and `doc_print`'s alike. -pub fn media_option(index: usize) -> Option<(String, String)> { - cups_media(index).map(|m| ("media".to_string(), m.to_string())) -} - -/// Whether most of page `index`'s text runs bottom-to-top as the page displays (a label laid -/// sideways on a letter or A4 sheet), counting each text-showing operator once; form XObjects -/// are followed a few levels deep. -pub fn text_reads_up(doc: &Document, index: usize) -> bool { - let Some(page) = pdfcraft_model::pages(doc).into_iter().nth(index) else { return false }; - let to_display = Matrix(page.view_matrix(doc)).invert().unwrap_or_default(); - let res = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); - let mut score = 0i64; - let mut budget = DecodeBudget::page(); - // Content past the budget is left out: the answer is a guess from the text read so far. - let data = decoded_contents(doc, &page.dict, &mut budget).unwrap_or_default(); - text_direction(doc, &data, &res, to_display, 0, &mut score, &mut budget); - score > 0 -} - -fn text_direction(doc: &Document, data: &[u8], res: &Dict, base: Matrix, depth: usize, score: &mut i64, budget: &mut DecodeBudget) { - let (mut ctm, mut tm, mut stack) = (base, Matrix::IDENTITY, Vec::new()); - for op in pdfcraft_content::parse(data).ops { - match op.op.as_slice() { - // Bounded like the renderer's graphics-state stack: deeper saves are ignored. - b"q" if stack.len() < 256 => stack.push(ctm), - b"Q" => ctm = stack.pop().unwrap_or(base), - b"cm" => ctm = Matrix::from_operands(&op.operands).unwrap_or_default().then(&ctm), - b"BT" => tm = Matrix::IDENTITY, - b"Tm" => tm = Matrix::from_operands(&op.operands).unwrap_or_default(), - b"Tj" | b"TJ" | b"'" | b"\"" => { - let [a, b, ..] = tm.then(&ctm).0; - *score += i64::from(b > a.abs()) - i64::from(-b > a.abs()); - } - b"Do" if depth < 4 => { - let Some(name) = op.name(0) else { continue }; - let Some(xobj) = res.get(b"XObject").and_then(|x| doc.resolve(x).as_dict().and_then(|x| x.get(name).cloned())) else { continue }; - let Object::Stream(form) = &*doc.resolve(&xobj) else { continue }; - if form.dict.get(b"Subtype").and_then(|s| doc.resolve(s).as_name().map(<[u8]>::to_vec)).as_deref() != Some(b"Form".as_slice()) { - continue; - } - let m = form.dict.get(b"Matrix").and_then(|m| doc.resolve(m).as_array().and_then(|a| Matrix::from_operands(a))).unwrap_or_default(); - let inner = form.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_else(|| res.clone()); - if !budget.enter_form() { - continue; - } - if let Ok(body) = budget.decode(form) { - text_direction(doc, &body, &inner, m.then(&ctm), depth + 1, score, budget); - } - } - _ => {} - } - } -} - -/// Margin kept around a label: thermal heads miss the very edge, and many 4×6 drivers feed -/// 100 × 150 mm (283 × 425 pt), a little short of the 288 × 432 pt sheet. -pub const LABEL_MARGIN: f64 = 8.0; - -/// Place `crop` (display space) of `page` centred in `cell`, scaled by `s` and turned by -/// `quarter` quarter turns counter-clockwise (0–3). -fn place_crop(page: usize, crop: [f64; 4], cell: [f64; 4], s: f64, quarter: u8) -> Placement { - let (w, h) = (crop[2] - crop[0], crop[3] - crop[1]); - let (pw, ph) = if quarter % 2 == 1 { (h * s, w * s) } else { (w * s, h * s) }; - let (x, y) = (cell[0] + (cell[2] - cell[0] - pw) / 2.0, cell[1] + (cell[3] - cell[1] - ph) / 2.0); - let [a, b, c, d, e, f] = match quarter % 4 { - 0 => [s, 0.0, 0.0, s, x, y], - 1 => [0.0, s, -s, 0.0, x + pw, y], - 2 => [-s, 0.0, 0.0, -s, x + pw, y + ph], - _ => [0.0, -s, s, 0.0, x, y + ph], - }; - // Move the crop's corner to the origin first. - Placement { page, matrix: Matrix([a, b, c, d, e - a * crop[0] - c * crop[1], f - b * crop[0] - d * crop[1]]), clip: crop } -} - -/// Match a displayed PDF page to a standard sheet, independent of orientation. -/// Half a point allows rounded metric PDF dimensions without confusing distinct sizes. -pub fn matching_paper(size: (f64, f64)) -> Option { - if !size.0.is_finite() || !size.1.is_finite() { - return None; - } - let size = (size.0.min(size.1), size.0.max(size.1)); - PAPERS.iter().position(|(_, p)| (p.0 - size.0).abs() <= 0.5 && (p.1 - size.1).abs() <= 0.5) -} - -/// The unprintable margin assumed around the sheet for Fit, Multiple and Booklet. -pub const MARGIN: f64 = 18.0; - -/// One page as placed on a sheet: the source page, the matrix from its *display space* to the -/// sheet, and the visible part of it in display space (clipping). -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct Placement { - pub page: usize, - pub matrix: Matrix, - pub clip: [f64; 4], -} - -/// One output sheet. -#[derive(Clone, Debug, Default, PartialEq)] -pub struct Sheet { - pub size: (f64, f64), - pub placed: Vec, - /// Page borders (Multiple) and cut marks (Poster), in sheet space: rectangles and lines. - pub borders: Vec<[f64; 4]>, - pub lines: Vec<[f64; 4]>, -} - -fn oriented(paper: (f64, f64), o: Orientation, landscape_wanted: bool) -> (f64, f64) { - let (w, h) = (paper.0.min(paper.1), paper.0.max(paper.1)); - match o { - Orientation::Portrait => (w, h), - Orientation::Landscape => (h, w), - Orientation::Auto => { - if landscape_wanted { - (h, w) - } else { - (w, h) - } - } - } -} - -/// Place a page of display size `(dw, dh)` inside `cell` (sheet space), scaled by `s` -/// (centred), optionally rotated 90° (counter-clockwise). -fn place_in(page: usize, (dw, dh): (f64, f64), cell: [f64; 4], s: f64, rotate: bool) -> Placement { - let (cw, ch) = (cell[2] - cell[0], cell[3] - cell[1]); - let (pw, ph) = if rotate { (dh * s, dw * s) } else { (dw * s, dh * s) }; - let (x, y) = (cell[0] + (cw - pw) / 2.0, cell[1] + (ch - ph) / 2.0); - let matrix = if rotate { Matrix([0.0, s, -s, 0.0, x + pw, y]) } else { Matrix([s, 0.0, 0.0, s, x, y]) }; - Placement { page, matrix, clip: [0.0, 0.0, dw, dh] } -} - -fn fit_scale((dw, dh): (f64, f64), (cw, ch): (f64, f64)) -> f64 { - if dw <= 0.0 || dh <= 0.0 { 1.0 } else { (cw / dw).min(ch / dh) } -} - -/// Lay out the sheets for `settings` given each source page's display size. -pub fn layout(sizes: &[(f64, f64)], settings: &Settings) -> Result, PrintError> { - let pages: Vec = settings.pages.iter().copied().filter(|p| *p < sizes.len()).collect(); - if pages.is_empty() { - return Err(PrintError::NoPages); - } - let paper = settings.paper; - if !(paper.0 >= 72.0 && paper.1 >= 72.0 && paper.0.is_finite() && paper.1.is_finite()) { - return Err(PrintError::Invalid("invalid paper size".into())); - } - let mut sheets = Vec::new(); - match settings.layout { - Layout::Size(mode) => { - for &p in &pages { - let d = sizes[p]; - let size = oriented(paper, settings.orientation, d.0 > d.1); - let fit = fit_scale(d, (size.0 - 2.0 * MARGIN, size.1 - 2.0 * MARGIN)); - let s = match mode { - SizeMode::Fit => fit, - SizeMode::Actual => 1.0, - SizeMode::Shrink => fit.min(1.0), - SizeMode::Custom(pct) if pct.is_finite() && pct > 0.0 => pct / 100.0, - SizeMode::Custom(_) => return Err(PrintError::Invalid("the scale must be more than 0%".into())), - }; - sheets.push(Sheet { size, placed: vec![place_in(p, d, [0.0, 0.0, size.0, size.1], s, false)], ..Sheet::default() }); - } - } - Layout::Label { flip } => { - let size = oriented(paper, settings.orientation, false); - let cell = [LABEL_MARGIN, LABEL_MARGIN, size.0 - LABEL_MARGIN, size.1 - LABEL_MARGIN]; - for &p in &pages { - let d = sizes[p]; - let crop = settings.crops.get(p).copied().flatten(); - let rect = crop - .map(|c| [c.rect[0].max(0.0), c.rect[1].max(0.0), c.rect[2].min(d.0), c.rect[3].min(d.1)]) - .filter(|r| r[2] - r[0] > 1.0 && r[3] - r[1] > 1.0) - .unwrap_or([0.0, 0.0, d.0, d.1]); - let (w, h) = (rect[2] - rect[0], rect[3] - rect[1]); - let turn = (w > h) != (cell[2] - cell[0] > cell[3] - cell[1]); - let mut quarter = match (turn, crop.is_some_and(|c| c.text_up)) { - (false, _) => 0, - (true, true) => 3, - (true, false) => 1, - }; - if flip { - quarter = (quarter + 2) % 4; - } - let s = if turn { - fit_scale((h, w), (cell[2] - cell[0], cell[3] - cell[1])) - } else { - fit_scale((w, h), (cell[2] - cell[0], cell[3] - cell[1])) - }; - sheets.push(Sheet { size, placed: vec![place_crop(p, rect, cell, s, quarter)], ..Sheet::default() }); - } - } - Layout::Multiple { cols, rows, per_sheet, order, border, auto_rotate } => { - let cells = cols.checked_mul(rows).filter(|&n| n > 0 && n <= 256); - let Some(cells) = cells else { - return Err(PrintError::Invalid("invalid number of pages per sheet".into())); - }; - // The grid can have more cells than the pages asked for on each sheet; the rest stay blank. - let per_sheet = per_sheet.min(cells); - if per_sheet == 0 { - return Err(PrintError::Invalid("invalid number of pages per sheet".into())); - } - // A grid with more columns than rows wants a landscape sheet, and the reverse. - let first = pages.first().and_then(|&p| sizes.get(p)).copied().ok_or(PrintError::NoPages)?; - let page_landscape = first.0 > first.1; - let (gc, gr, sheet_landscape) = if cols == rows { - (cols, rows, page_landscape) - } else if settings.orientation == Orientation::Landscape || (settings.orientation == Orientation::Auto && cols < rows) { - // 2 per sheet (1 × 2 on portrait) prints side by side on landscape paper. - (rows, cols, true) - } else { - (cols, rows, false) - }; - let size = oriented(paper, settings.orientation, sheet_landscape); - let gap = 6.0; - let (aw, ah) = (size.0 - 2.0 * MARGIN, size.1 - 2.0 * MARGIN); - let (cw, ch) = ((aw - gap * (gc - 1) as f64) / gc as f64, (ah - gap * (gr - 1) as f64) / gr as f64); - if cw <= 0.0 || ch <= 0.0 { - return Err(PrintError::Invalid("the page grid does not fit on the paper".into())); - } - let sheet_count = pages.len().div_ceil(per_sheet); - for sheet_index in 0..sheet_count { - let mut sheet = Sheet { size, ..Sheet::default() }; - if order == PageOrder::CutStack { - // Marks align across every sheet, including unoccupied cells. Cut in the - // middle of each gutter; marks stay in the printable area, clear of cells. - for col in 1..gc { - let x = MARGIN + col as f64 * (cw + gap) - gap / 2.0; - sheet.lines.extend([[x, MARGIN, x, MARGIN + 8.0], [x, size.1 - MARGIN - 8.0, x, size.1 - MARGIN]]); - } - for row in 1..gr { - let y = size.1 - MARGIN - row as f64 * (ch + gap) + gap / 2.0; - sheet.lines.extend([[MARGIN, y, MARGIN + 8.0, y], [size.0 - MARGIN - 8.0, y, size.0 - MARGIN, y]]); - } - } - for k in 0..per_sheet { - // A cell's pile gets consecutive pages. Missing pages leave blank cells, - // rather than shifting later piles left on the final sheets. - let index = if order == PageOrder::CutStack { - k.checked_mul(sheet_count).and_then(|n| n.checked_add(sheet_index)) - } else { - sheet_index.checked_mul(per_sheet).and_then(|n| n.checked_add(k)) - }; - let Some(p) = index.and_then(|i| pages.get(i)).copied() else { continue }; - - let (col, row) = match order { - PageOrder::Horizontal | PageOrder::CutStack => (k % gc, k / gc), - PageOrder::HorizontalReversed => (gc - 1 - k % gc, k / gc), - PageOrder::Vertical => (k / gr, k % gr), - PageOrder::VerticalReversed => (gc - 1 - k / gr, k % gr), - }; - let x0 = MARGIN + col as f64 * (cw + gap); - let y1 = size.1 - MARGIN - row as f64 * (ch + gap); - let cell = [x0, y1 - ch, x0 + cw, y1]; - let d = sizes.get(p).copied().ok_or_else(|| PrintError::Invalid("page size is missing".into()))?; - if !(d.0.is_finite() && d.1.is_finite() && d.0 > 0.0 && d.1 > 0.0) { - return Err(PrintError::Invalid("invalid page size".into())); - } - let rotate = auto_rotate && (d.0 > d.1) != (cw > ch) && (d.0 - d.1).abs() > 1.0; - let s = fit_scale(if rotate { (d.1, d.0) } else { d }, (cw, ch)); - if !s.is_finite() || s <= 0.0 { - return Err(PrintError::Invalid("page size cannot be scaled to the grid".into())); - } - let pl = place_in(p, d, cell, s, rotate); - if border { - let b = pl.matrix.bbox(pl.clip); - sheet.borders.push(b); - } - sheet.placed.push(pl); - } - sheets.push(sheet); - } - } - Layout::Booklet { subset, binding } => { - // Pad to a multiple of 4 with blank pages (None). - let n4 = pages.len().div_ceil(4) * 4; - let at = |i: usize| pages.get(i).copied(); - let size = oriented(paper, Orientation::Landscape, true); - let half = (size.0 - 2.0 * MARGIN) / 2.0; - let cells = [[MARGIN, MARGIN, MARGIN + half, size.1 - MARGIN], [MARGIN + half, MARGIN, size.0 - MARGIN, size.1 - MARGIN]]; - for k in 0..n4 / 4 { - let front = (at(n4 - 1 - 2 * k), at(2 * k)); - let back = (at(2 * k + 1), at(n4 - 2 - 2 * k)); - let mut sides = Vec::new(); - if subset != BookletSubset::BackOnly { - sides.push(front); - } - if subset != BookletSubset::FrontOnly { - sides.push(back); - } - for (l, r) in sides { - let (l, r) = if binding == Binding::Right { (r, l) } else { (l, r) }; - let mut sheet = Sheet { size, ..Sheet::default() }; - for (p, cell) in [(l, cells[0]), (r, cells[1])] { - if let Some(p) = p { - let d = sizes[p]; - let s = fit_scale(d, (cell[2] - cell[0], cell[3] - cell[1])); - sheet.placed.push(place_in(p, d, cell, s, false)); - } - } - sheets.push(sheet); - } - } - } - Layout::Poster { scale, overlap, cut_marks } => { - if !(scale.is_finite() && scale > 0.0 && overlap.is_finite() && overlap >= 0.0) { - return Err(PrintError::Invalid("invalid tile scale or overlap".into())); - } - let s = scale / 100.0; - for &p in &pages { - let d = sizes[p]; - let size = oriented(paper, settings.orientation, d.0 > d.1); - // Each tile shows `step` of the scaled page plus `overlap` shared with the next. - let (tw, th) = (size.0 - 2.0 * MARGIN, size.1 - 2.0 * MARGIN); - if overlap * 2.0 >= tw.min(th) { - return Err(PrintError::Invalid("the overlap is larger than the tile".into())); - } - let (sw, sh) = (d.0 * s, d.1 * s); - let (stepx, stepy) = (tw - overlap, th - overlap); - let nx = (((sw - overlap) / stepx).ceil() as usize).max(1); - let ny = (((sh - overlap) / stepy).ceil() as usize).max(1); - // The counts saturate at usize::MAX for a huge scale, so their product can overflow. - if nx.checked_mul(ny).is_none_or(|n| n > 1024) { - return Err(PrintError::Invalid("the poster would need more than 1024 sheets".into())); - } - // Tiles from the top-left, row by row. - for ty in 0..ny { - for tx in 0..nx { - // The window into the scaled page (scaled display space, y up). - let wx0 = tx as f64 * stepx; - let wy1 = sh - ty as f64 * stepy; - let matrix = Matrix([s, 0.0, 0.0, s, MARGIN - wx0, size.1 - MARGIN - wy1]); - let clip = [wx0 / s, (wy1 - th) / s, (wx0 + tw) / s, wy1 / s]; - let mut sheet = Sheet { size, placed: vec![Placement { page: p, matrix, clip }], ..Sheet::default() }; - if cut_marks { - let (x0, y0, x1, y1) = (MARGIN, MARGIN, size.0 - MARGIN, size.1 - MARGIN); - for (x, y) in [(x0, y0), (x1, y0), (x0, y1), (x1, y1)] { - let dx = if x == x0 { -1.0 } else { 1.0 }; - let dy = if y == y0 { -1.0 } else { 1.0 }; - sheet.lines.push([x + dx * 2.0, y, x + dx * (MARGIN - 2.0), y]); - sheet.lines.push([x, y + dy * 2.0, x, y + dy * (MARGIN - 2.0)]); - } - } - sheets.push(sheet); - } - } - } - } - } - Ok(sheets) -} - -// Model-derived transforms and clipping bounds need their full PDF decimal precision. -fn geometry_number(value: f64) -> String { - let mut bytes = Vec::new(); - pdfcraft_cos::serialize(&Object::Real(value), &mut bytes); - String::from_utf8_lossy(&bytes).into_owned() -} - -fn n(v: f64) -> String { - let s = format!("{v:.4}"); - let s = s.trim_end_matches('0').trim_end_matches('.'); - if s == "-0" { "0".into() } else { s.into() } -} - -/// The page's content streams decoded against `budget` and joined; streams that don't decode -/// are left out. `None` when the budget ran out, so part of the content is missing. -fn decoded_contents(doc: &Document, page: &Dict, budget: &mut DecodeBudget) -> Option> { - let Some(contents) = page.get(b"Contents") else { return Some(Vec::new()) }; - let mut out = Vec::new(); - for data in budget.decode_contents(doc, contents) { - match data { - Ok(data) => { - out.extend_from_slice(&data); - out.push(b'\n'); - } - Err(_) if budget.remaining() == 0 => return None, - Err(_) => {} - } - } - Some(out) -} - -/// Reduce `doc`'s pages to what [`impose`] draws of them under `content`: the annotations that -/// don't print are dropped, and with [`Content::FormFieldsOnly`] the page content too. For -/// printing pages as pictures, where a renderer draws each page as it stands. -pub fn keep_printed(doc: &mut Document, content: Content) { - for page in pdfcraft_model::pages(doc) { - let annots = page.dict.get(b"Annots").and_then(|a| doc.resolve(a).as_array().cloned()).unwrap_or_default(); - let kept: Vec = annots.into_iter().filter(|a| doc.resolve(a).as_dict().is_some_and(|d| prints(doc, d, content))).collect(); - // Fails only when the page object isn't a dictionary, and such a page draws nothing. - let _ = doc.update_dict(page.obj, |d| { - if content == Content::FormFieldsOnly { - d.remove(b"Contents"); - } - if d.contains(b"Annots") { - d.set(b"Annots".to_vec(), Object::Array(kept)); - } - }); - } -} - -/// The standard annotation types of ISO 32000-2 Table 171. -const STANDARD_ANNOTATION_TYPES: [&[u8]; 28] = [ - b"Text", - b"Link", - b"FreeText", - b"Line", - b"Square", - b"Circle", - b"Polygon", - b"PolyLine", - b"Highlight", - b"Underline", - b"Squiggly", - b"StrikeOut", - b"Caret", - b"Stamp", - b"Ink", - b"Popup", - b"FileAttachment", - b"Sound", - b"Movie", - b"Screen", - b"Widget", - b"PrinterMark", - b"TrapNet", - b"Watermark", - b"3D", - b"Redact", - b"Projection", - b"RichMedia", -]; - -/// Does annotation `d` print under `content`? -fn prints(doc: &Document, d: &Dict, content: Content) -> bool { - const INVISIBLE: i64 = 1; - const HIDDEN: i64 = 2; - const PRINT: i64 = 4; - let flags = d.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0); - if flags & PRINT == 0 || flags & HIDDEN != 0 { - return false; - } - // `/Subtype` may be an indirect reference (ISO 32000-2 §7.3.10), so resolve it first. - let subtype_obj = d.get(b"Subtype").map(|s| doc.resolve(s)); - let subtype = subtype_obj.as_deref().and_then(Object::as_name).unwrap_or(b""); - match subtype { - b"Widget" => true, - b"Link" | b"Popup" => false, - b"Stamp" => content != Content::Document && content != Content::FormFieldsOnly, - // ISO 32000-2 Table 167: an Invisible annotation of a type that isn't standard (Table - // 171) is not printed even with its Print flag set. - _ if flags & INVISIBLE != 0 && !STANDARD_ANNOTATION_TYPES.contains(&subtype) => false, - _ => content == Content::DocumentAndMarkups, - } -} - -/// The normal appearance stream to draw for an annotation (honouring `/AS`). -fn appearance(doc: &Document, d: &Dict) -> Option { - let ap = doc.resolve(d.get(b"AP")?); - let normal = ap.as_dict()?.get(b"N")?.clone(); - match &*doc.resolve(&normal) { - Object::Stream(_) => normal.as_ref(), - Object::Dict(states) => { - let state = d.name(b"AS")?; - states.get(state)?.as_ref() - } - _ => None, - } -} - -/// Algorithm 8.1: the matrix placing an appearance form (BBox × Matrix) onto the annotation rect. -fn appearance_matrix(doc: &Document, form: &Dict, rect: [f64; 4]) -> Option { - let nums = - |k: &[u8]| -> Option> { Some(doc.resolve(form.get(k)?).as_array()?.iter().filter_map(|x| doc.resolve(x).as_f64()).collect()) }; - let bbox = nums(b"BBox").filter(|b| b.len() == 4)?; - let m = nums(b"Matrix").filter(|m| m.len() == 6).map(|m| Matrix([m[0], m[1], m[2], m[3], m[4], m[5]])).unwrap_or_default(); - let b = m.bbox([bbox[0], bbox[1], bbox[2], bbox[3]]); - let (bw, bh) = (b[2] - b[0], b[3] - b[1]); - if bw <= 0.0 || bh <= 0.0 { - return None; - } - let a = Matrix([ - (rect[2] - rect[0]) / bw, - 0.0, - 0.0, - (rect[3] - rect[1]) / bh, - rect[0] - b[0] * (rect[2] - rect[0]) / bw, - rect[1] - b[1] * (rect[3] - rect[1]) / bh, - ]); - // The Do that draws the form applies its /Matrix itself, so only the placement goes here. - Some(a) -} - -/// A Form XObject holding page `index` as it prints (user space; BBox = crop box). -/// `pages` are the document's pages, listed once by the caller: listing them for each page -/// printed would make printing quadratic in the page count. -fn page_form( - doc: &mut Document, - pages: &[pdfcraft_model::Page], - index: usize, - content: Content, - promoted: &mut std::collections::HashMap, -) -> Result { - let page = pages.get(index).ok_or_else(|| PrintError::Invalid(format!("there is no page {}", index.saturating_add(1))))?; - let crop = page.crop(doc); - let mut res = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); - let mut body = if content == Content::FormFieldsOnly { - Vec::new() - } else { - decoded_contents(doc, &page.dict, &mut DecodeBudget::page()) - .ok_or_else(|| PrintError::Invalid(format!("page {} has more content than can be printed", index + 1)))? - }; - // Wrap the page content so a state it leaves behind can't affect the annotations. - if !body.is_empty() { - let mut wrapped = b"q\n".to_vec(); - wrapped.extend_from_slice(&body); - wrapped.extend_from_slice(b"\nQ\n"); - body = wrapped; - } - let annots = page.dict.get(b"Annots").and_then(|a| doc.resolve(a).as_array().cloned()).unwrap_or_default(); - let mut xobjects = res.get(b"XObject").and_then(|x| doc.resolve(x).as_dict().cloned()).unwrap_or_default(); - let mut k = 0usize; - for a in annots { - let Some(d) = doc.resolve(&a).as_dict().cloned() else { continue }; - if !prints(doc, &d, content) { - continue; - } - let rect: Vec = - d.get(b"Rect").and_then(|r| doc.resolve(r).as_array().map(|a| a.iter().filter_map(Object::as_f64).collect())).unwrap_or_default(); - if rect.len() != 4 { - continue; - } - let rect = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])]; - let Some(ap) = appearance(doc, &d) else { continue }; - let form = doc.get(ap).as_dict().cloned().unwrap_or_default(); - let Some(m) = appearance_matrix(doc, &form, rect) else { continue }; - let ap = if !form.contains(b"Subtype") - && let Object::Stream(stream) = &*doc.get(ap) - { - // Some existing annotation appearances omit Subtype, but Do dispatch needs a Form XObject. - // Keep the original intact: other document objects can share this appearance. - if let Some(&cached) = promoted.get(&ap) { - cached - } else { - let mut stream = stream.clone(); - stream.dict.set(b"Subtype".to_vec(), Object::name("Form")); - let copy = doc.add(Object::Stream(stream)); - promoted.insert(ap, copy); - copy - } - } else { - ap - }; - // Keep the page's own XObjects, including names previous print operations generated. - // Check the growing map so each appearance also keeps the name just assigned to it. - let name = loop { - let name = format!("PCPrA{k}"); - k = k.checked_add(1).ok_or_else(|| PrintError::Invalid("too many annotation appearance resources".into()))?; - if !xobjects.contains(name.as_bytes()) { - break name; - } - }; - let [a0, b0, c0, d0, e0, f0] = m.0; - body.extend(format!("q {} {} {} {} {} {} cm /{name} Do Q\n", n(a0), n(b0), n(c0), n(d0), n(e0), n(f0)).bytes()); - xobjects.set(name.into_bytes(), Object::Ref(ap)); - } - if !xobjects.is_empty() { - res.set(b"XObject".to_vec(), Object::Dict(xobjects)); - } - let mut d = Dict::new(); - d.set(b"Type".to_vec(), Object::name("XObject")); - d.set(b"Subtype".to_vec(), Object::name("Form")); - d.set(b"BBox".to_vec(), Object::Array(crop.iter().map(|v| Object::Real(*v)).collect())); - d.set(b"Resources".to_vec(), Object::Dict(res)); - if let Some(g) = page.dict.get(b"Group") { - d.set(b"Group".to_vec(), g.clone()); - } - Ok(doc.add(Object::Stream(Stream::flate(d, &body)))) -} +mod error; +mod imposition; +mod settings; +mod sheets; +pub use error::{PrintError, WORDINGS, Words, in_english}; #[cfg(test)] -thread_local! { - /// Pages listed by [`source_pages`] on this thread. - static PAGES_LISTED: std::cell::Cell = const { std::cell::Cell::new(0) }; -} - -/// The source document's pages, for printing. -fn source_pages(doc: &Document) -> Vec { - let pages = pdfcraft_model::pages(doc); - #[cfg(test)] - PAGES_LISTED.with(|c| c.set(c.get() + pages.len())); - pages -} - -/// Build the print-ready PDF for `settings`. Encryption is not carried over (the file goes to a -/// printer or is the user's own copy); callers must check the print permission first. -pub fn impose(src: &Document, settings: &Settings) -> Result, PrintError> { - let pages = source_pages(src); - let sizes: Vec<(f64, f64)> = pages.iter().map(|p| p.display_size(src)).collect(); - let sheets = layout(&sizes, settings)?; - let mut doc = src.clone(); - // One form per source page used (a page may appear on several sheets: posters). - let mut forms: std::collections::HashMap = std::collections::HashMap::new(); - let mut promoted = std::collections::HashMap::new(); - let views: Vec<[f64; 6]> = pages.iter().map(|p| p.view_matrix(src)).collect(); - let pages_root = doc.add(Object::Null); - let mut kids = Vec::new(); - for sheet in &sheets { - let mut c = String::new(); - let mut xo = Dict::new(); - for (i, pl) in sheet.placed.iter().enumerate() { - let form = match forms.get(&pl.page) { - Some(f) => *f, - None => { - let f = page_form(&mut doc, &pages, pl.page, settings.content, &mut promoted)?; - forms.insert(pl.page, f); - f - } - }; - let name = format!("P{i}"); - xo.set(name.clone().into_bytes(), Object::Ref(form)); - // Display space → sheet, then clip to the visible part, then user → display. - let [a, b, cc, d, e, f] = pl.matrix.0; - let to_display = Matrix(views[pl.page]).invert().unwrap_or_default(); - let [ua, ub, uc, ud, ue, uf] = to_display.0; - let [x0, y0, x1, y1] = pl.clip; - c.push_str(&format!( - "q {} {} {} {} {} {} cm {} {} {} {} re W n {} {} {} {} {} {} cm /{name} Do Q\n", - geometry_number(a), - geometry_number(b), - geometry_number(cc), - geometry_number(d), - geometry_number(e), - geometry_number(f), - geometry_number(x0), - geometry_number(y0), - geometry_number(x1 - x0), - geometry_number(y1 - y0), - geometry_number(ua), - geometry_number(ub), - geometry_number(uc), - geometry_number(ud), - geometry_number(ue), - geometry_number(uf) - )); - } - if !sheet.borders.is_empty() || !sheet.lines.is_empty() { - c.push_str("q 0 G 0.5 w\n"); - for r in &sheet.borders { - c.push_str(&format!("{} {} {} {} re S\n", n(r[0]), n(r[1]), n(r[2] - r[0]), n(r[3] - r[1]))); - } - for l in &sheet.lines { - c.push_str(&format!("{} {} m {} {} l S\n", n(l[0]), n(l[1]), n(l[2]), n(l[3]))); - } - c.push_str("Q\n"); - } - let contents = doc.add(Object::Stream(Stream::flate(Dict::new(), c.as_bytes()))); - let mut res = Dict::new(); - res.set(b"XObject".to_vec(), Object::Dict(xo)); - let mut p = Dict::new(); - p.set(b"Type".to_vec(), Object::name("Page")); - p.set(b"Parent".to_vec(), Object::Ref(pages_root)); - p.set(b"MediaBox".to_vec(), Object::Array([0.0, 0.0, sheet.size.0, sheet.size.1].iter().map(|v| Object::Real(*v)).collect())); - p.set(b"Resources".to_vec(), Object::Dict(res)); - p.set(b"Contents".to_vec(), Object::Ref(contents)); - kids.push(Object::Ref(doc.add(Object::Dict(p)))); - } - let mut tree = Dict::new(); - tree.set(b"Type".to_vec(), Object::name("Pages")); - tree.set(b"Count".to_vec(), Object::Int(kids.len() as i64)); - tree.set(b"Kids".to_vec(), Object::Array(kids)); - doc.set(pages_root, Object::Dict(tree)); - let mut cat = Dict::new(); - cat.set(b"Type".to_vec(), Object::name("Catalog")); - cat.set(b"Pages".to_vec(), Object::Ref(pages_root)); - // Keep the layers' configuration, or a layer that is off in the document would print: the - // pages' marked content still refers to its groups, and without this nothing says they are off. - let layers = src.trailer().get(b"Root").and_then(|r| src.resolve(r).as_dict().and_then(|root| root.get(b"OCProperties").cloned())); - if let Some(layers) = layers { - cat.set(b"OCProperties".to_vec(), layers); - } - let root = doc.add(Object::Dict(cat)); - doc.trailer_mut().set(b"Root".to_vec(), Object::Ref(root)); - doc.trailer_mut().remove(b"Info"); - if doc.trailer().contains(b"Encrypt") { - doc.remove_encryption(); - } - Ok(write_full(&doc, &SaveOptions::default())?) -} - -/// Which source pages land on each sheet (for previews and the sheet count). -pub fn sheet_pages(sheets: &[Sheet]) -> Vec> { - sheets.iter().map(|s| s.placed.iter().map(|p| p.page).collect()).collect() -} +use imposition::{PAGES_LISTED, appearance, appearance_matrix, decoded_contents, page_form, prints}; +pub use imposition::{impose, keep_printed, sheet_pages}; +pub use settings::{ + Binding, BookletSubset, Content, Crop, LABEL_PAPER, Layout, Orientation, PAPERS, PageOrder, Raster, Settings, SizeMode, booklet_duplex, + cups_media, media_option, +}; +#[cfg(test)] +use sheets::text_direction; +pub use sheets::{LABEL_MARGIN, MARGIN, Placement, Sheet, layout, matching_paper, text_reads_up}; diff --git a/crates/print/src/settings.rs b/crates/print/src/settings.rs new file mode 100644 index 0000000000..0506eb5b18 --- /dev/null +++ b/crates/print/src/settings.rs @@ -0,0 +1,250 @@ +use super::spool; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum Orientation { + #[default] + Auto, + Portrait, + Landscape, +} + +#[derive(Clone, Copy, Debug, PartialEq, Default)] +pub enum SizeMode { + #[default] + Fit, + Actual, + Shrink, + /// Percent. + Custom(f64), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum PageOrder { + #[default] + Horizontal, + HorizontalReversed, + Vertical, + VerticalReversed, + /// Single-sided sheets: cut into cell piles, then stack them in row-major order. + CutStack, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum BookletSubset { + #[default] + BothSides, + FrontOnly, + BackOnly, +} + +/// How a booklet goes through the printer (#2707): it decides two-sided printing itself, as +/// Acrobat does (its Print dialog hides Two-sided while Booklet is chosen; Using Acrobat X Pro, +/// Print dialog box options: "Acrobat automatically enables duplex printing, if available, for +/// booklet printing"). Both sides flip on the short edge, the fold of a 2-up sheet turned +/// sideways; front side only or back side only is one pass of a manual duplex and prints +/// single-sided. +pub fn booklet_duplex(subset: BookletSubset) -> spool::Duplex { + match subset { + BookletSubset::BothSides => spool::Duplex::ShortEdge, + BookletSubset::FrontOnly | BookletSubset::BackOnly => spool::Duplex::Off, + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum Binding { + #[default] + Left, + Right, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Layout { + Size(SizeMode), + Multiple { + cols: usize, + rows: usize, + /// Source pages on each sheet, at most `cols × rows`: three per sheet uses a 2 × 2 grid + /// with one cell left blank. + per_sheet: usize, + order: PageOrder, + border: bool, + auto_rotate: bool, + }, + Booklet { + subset: BookletSubset, + binding: Binding, + }, + /// Tile scale (percent), overlap (points), cut marks. + Poster { + scale: f64, + overlap: f64, + cut_marks: bool, + }, + /// Shipping label: each page cut to its [`Settings::crops`] box, turned so its text reads + /// upright on the sheet and fitted with [`LABEL_MARGIN`]; `flip` turns it a further 180°. + Label { + flip: bool, + }, +} + +impl Default for Layout { + fn default() -> Self { + Layout::Size(SizeMode::Fit) + } +} + +impl Layout { + /// Acrobat's pages-per-sheet choices as a grid (columns × rows on a portrait sheet). + pub fn multiple(per_sheet: usize) -> Layout { + let (cols, rows) = match per_sheet { + 2 => (1, 2), + 4 => (2, 2), + 6 => (2, 3), + 9 => (3, 3), + 16 => (4, 4), + n => { + let c = (n as f64).sqrt().ceil().max(1.0) as usize; + (c, n.div_ceil(c).max(1)) + } + }; + Layout::Multiple { cols, rows, per_sheet: per_sheet.max(1), order: PageOrder::Horizontal, border: false, auto_rotate: true } + } +} + +/// Acrobat's Comments & Forms choices. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum Content { + /// Page content and form fields. + Document, + /// Page content, form fields and every printable comment. + #[default] + DocumentAndMarkups, + /// Page content, form fields and stamps. + DocumentAndStamps, + /// Form fields only. + FormFieldsOnly, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Settings { + /// Source pages (0-based) in print order; see [`select_pages`]. + pub pages: Vec, + /// Paper (width, height) in points, portrait. + pub paper: (f64, f64), + pub orientation: Orientation, + pub layout: Layout, + pub content: Content, + /// For [`Layout::Label`], per source page: the area to keep (display space) and whether its + /// text runs bottom-to-top (so a quarter turn clockwise makes it upright). `None` or a + /// missing entry keeps the whole page. + pub crops: Vec>, + /// Print as image (Acrobat's Advanced Print Setup): every page rendered to a picture at this + /// resolution before it is placed on the paper. The engine does the rendering; `impose` is + /// then given the image-only document. + pub raster: Option, +} + +/// How pages are rendered when printing as image. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Raster { + /// Dots per inch, 72–600 (Acrobat offers 150, 300 and 600). + pub dpi: f64, + /// Render the pictures in grey, so the print-ready PDF itself is grayscale. + pub grayscale: bool, +} + +impl Default for Raster { + fn default() -> Self { + Raster { dpi: 300.0, grayscale: false } + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Crop { + pub rect: [f64; 4], + pub text_up: bool, +} + +impl Default for Settings { + fn default() -> Self { + Settings { + pages: Vec::new(), + paper: PAPERS[0].1, + orientation: Orientation::Auto, + layout: Layout::default(), + content: Content::default(), + raster: None, + crops: Vec::new(), + } + } +} + +/// Common paper sizes (points, portrait). +pub const PAPERS: [(&str, (f64, f64)); 21] = [ + ("US Letter", (612.0, 792.0)), + ("US Legal", (612.0, 1008.0)), + ("Tabloid", (792.0, 1224.0)), + ("A3", (841.89, 1190.55)), + ("A4", (595.28, 841.89)), + ("A5", (419.53, 595.28)), + ("A6", (297.64, 419.53)), + ("ARCH A (9 x 12 in)", (648.0, 864.0)), + ("ARCH B (12 x 18 in)", (864.0, 1296.0)), + ("ARCH C (18 x 24 in)", (1296.0, 1728.0)), + ("ARCH D (24 x 36 in)", (1728.0, 2592.0)), + ("ARCH E1 (30 x 42 in)", (2160.0, 3024.0)), + ("ARCH E (36 x 48 in)", (2592.0, 3456.0)), + ("ANSI C (17 x 22 in)", (1224.0, 1584.0)), + ("ANSI D (22 x 34 in)", (1584.0, 2448.0)), + ("ANSI E (34 x 44 in)", (2448.0, 3168.0)), + ("A2", (1190.55, 1683.78)), + ("A1", (1683.78, 2383.94)), + ("A0", (2383.94, 3370.39)), + ("4 x 6 in label (101.6 x 152.4 mm)", (288.0, 432.0)), + ("US #10 Envelope (4.125 x 9.5 in)", (297.0, 684.0)), +]; + +/// `PAPERS` index of the 4 × 6 in shipping label. +pub const LABEL_PAPER: usize = 19; + +/// The CUPS media name for `PAPERS[index]`, so the spooler feeds the sheet the PDF was laid out +/// on rather than the queue's default; `None` only past the end of `PAPERS`. The common sizes +/// use their PPD keywords (`w288h432` is the usual 4×6 label, `Env10` the US #10 envelope). +/// The rest use PWG 5101.1 names (ARCH E1 is its `wide-format` size), spelled exactly as the +/// spec and the IANA registry have them: a driverless printer may take only the names it +/// advertises. They carry their dimensions, so CUPS feeds the PPD size of those dimensions +/// whatever the driver calls it (`ARCHD`, `AnsiD`) (#3752). +pub fn cups_media(index: usize) -> Option<&'static str> { + Some(match PAPERS.get(index)?.0 { + "US Letter" => "Letter", + "US Legal" => "Legal", + "Tabloid" => "Tabloid", + "A3" => "A3", + "A4" => "A4", + "A5" => "A5", + "A6" => "iso_a6_105x148mm", + "ARCH A (9 x 12 in)" => "na_arch-a_9x12in", + "ARCH B (12 x 18 in)" => "na_arch-b_12x18in", + "ARCH C (18 x 24 in)" => "na_arch-c_18x24in", + "ARCH D (24 x 36 in)" => "na_arch-d_24x36in", + "ARCH E1 (30 x 42 in)" => "na_wide-format_30x42in", + "ARCH E (36 x 48 in)" => "na_arch-e_36x48in", + "ANSI C (17 x 22 in)" => "na_c_17x22in", + "ANSI D (22 x 34 in)" => "na_d_22x34in", + "ANSI E (34 x 44 in)" => "na_e_34x44in", + "A2" => "iso_a2_420x594mm", + "A1" => "iso_a1_594x841mm", + "A0" => "iso_a0_841x1189mm", + "US #10 Envelope (4.125 x 9.5 in)" => "Env10", + _ if index == LABEL_PAPER => "w288h432", + // A size added to PAPERS without a name here: its tests fail, and meanwhile the queue's + // paper stands, as it did for every size before #1583. + _ => return None, + }) +} + +/// The job option that feeds `PAPERS[index]` (`media`, see [`cups_media`]), for the Print +/// dialog's jobs and `doc_print`'s alike. +pub fn media_option(index: usize) -> Option<(String, String)> { + cups_media(index).map(|m| ("media".to_string(), m.to_string())) +} diff --git a/crates/print/src/sheets.rs b/crates/print/src/sheets.rs new file mode 100644 index 0000000000..061a6ed5c3 --- /dev/null +++ b/crates/print/src/sheets.rs @@ -0,0 +1,357 @@ +use super::error::PrintError; +use super::imposition::decoded_contents; +use super::settings::{Binding, BookletSubset, Layout, Orientation, PAPERS, PageOrder, Settings, SizeMode}; +use super::{DecodeBudget, Dict, Document, Matrix, Object}; + +/// Whether most of page `index`'s text runs bottom-to-top as the page displays (a label laid +/// sideways on a letter or A4 sheet), counting each text-showing operator once; form XObjects +/// are followed a few levels deep. +pub fn text_reads_up(doc: &Document, index: usize) -> bool { + let Some(page) = pdfcraft_model::pages(doc).into_iter().nth(index) else { return false }; + let to_display = Matrix(page.view_matrix(doc)).invert().unwrap_or_default(); + let res = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); + let mut score = 0i64; + let mut budget = DecodeBudget::page(); + // Content past the budget is left out: the answer is a guess from the text read so far. + let data = decoded_contents(doc, &page.dict, &mut budget).unwrap_or_default(); + text_direction(doc, &data, &res, to_display, 0, &mut score, &mut budget); + score > 0 +} + +pub(crate) fn text_direction(doc: &Document, data: &[u8], res: &Dict, base: Matrix, depth: usize, score: &mut i64, budget: &mut DecodeBudget) { + let (mut ctm, mut tm, mut stack) = (base, Matrix::IDENTITY, Vec::new()); + for op in pdfcraft_content::parse(data).ops { + match op.op.as_slice() { + // Bounded like the renderer's graphics-state stack: deeper saves are ignored. + b"q" if stack.len() < 256 => stack.push(ctm), + b"Q" => ctm = stack.pop().unwrap_or(base), + b"cm" => ctm = Matrix::from_operands(&op.operands).unwrap_or_default().then(&ctm), + b"BT" => tm = Matrix::IDENTITY, + b"Tm" => tm = Matrix::from_operands(&op.operands).unwrap_or_default(), + b"Tj" | b"TJ" | b"'" | b"\"" => { + let [a, b, ..] = tm.then(&ctm).0; + *score += i64::from(b > a.abs()) - i64::from(-b > a.abs()); + } + b"Do" if depth < 4 => { + let Some(name) = op.name(0) else { continue }; + let Some(xobj) = res.get(b"XObject").and_then(|x| doc.resolve(x).as_dict().and_then(|x| x.get(name).cloned())) else { continue }; + let Object::Stream(form) = &*doc.resolve(&xobj) else { continue }; + if form.dict.get(b"Subtype").and_then(|s| doc.resolve(s).as_name().map(<[u8]>::to_vec)).as_deref() != Some(b"Form".as_slice()) { + continue; + } + let m = form.dict.get(b"Matrix").and_then(|m| doc.resolve(m).as_array().and_then(|a| Matrix::from_operands(a))).unwrap_or_default(); + let inner = form.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_else(|| res.clone()); + if !budget.enter_form() { + continue; + } + if let Ok(body) = budget.decode(form) { + text_direction(doc, &body, &inner, m.then(&ctm), depth + 1, score, budget); + } + } + _ => {} + } + } +} + +/// Margin kept around a label: thermal heads miss the very edge, and many 4×6 drivers feed +/// 100 × 150 mm (283 × 425 pt), a little short of the 288 × 432 pt sheet. +pub const LABEL_MARGIN: f64 = 8.0; + +/// Place `crop` (display space) of `page` centred in `cell`, scaled by `s` and turned by +/// `quarter` quarter turns counter-clockwise (0–3). +fn place_crop(page: usize, crop: [f64; 4], cell: [f64; 4], s: f64, quarter: u8) -> Placement { + let (w, h) = (crop[2] - crop[0], crop[3] - crop[1]); + let (pw, ph) = if quarter % 2 == 1 { (h * s, w * s) } else { (w * s, h * s) }; + let (x, y) = (cell[0] + (cell[2] - cell[0] - pw) / 2.0, cell[1] + (cell[3] - cell[1] - ph) / 2.0); + let [a, b, c, d, e, f] = match quarter % 4 { + 0 => [s, 0.0, 0.0, s, x, y], + 1 => [0.0, s, -s, 0.0, x + pw, y], + 2 => [-s, 0.0, 0.0, -s, x + pw, y + ph], + _ => [0.0, -s, s, 0.0, x, y + ph], + }; + // Move the crop's corner to the origin first. + Placement { page, matrix: Matrix([a, b, c, d, e - a * crop[0] - c * crop[1], f - b * crop[0] - d * crop[1]]), clip: crop } +} + +/// Match a displayed PDF page to a standard sheet, independent of orientation. +/// Half a point allows rounded metric PDF dimensions without confusing distinct sizes. +pub fn matching_paper(size: (f64, f64)) -> Option { + if !size.0.is_finite() || !size.1.is_finite() { + return None; + } + let size = (size.0.min(size.1), size.0.max(size.1)); + PAPERS.iter().position(|(_, p)| (p.0 - size.0).abs() <= 0.5 && (p.1 - size.1).abs() <= 0.5) +} + +/// The unprintable margin assumed around the sheet for Fit, Multiple and Booklet. +pub const MARGIN: f64 = 18.0; + +/// One page as placed on a sheet: the source page, the matrix from its *display space* to the +/// sheet, and the visible part of it in display space (clipping). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Placement { + pub page: usize, + pub matrix: Matrix, + pub clip: [f64; 4], +} + +/// One output sheet. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Sheet { + pub size: (f64, f64), + pub placed: Vec, + /// Page borders (Multiple) and cut marks (Poster), in sheet space: rectangles and lines. + pub borders: Vec<[f64; 4]>, + pub lines: Vec<[f64; 4]>, +} + +fn oriented(paper: (f64, f64), o: Orientation, landscape_wanted: bool) -> (f64, f64) { + let (w, h) = (paper.0.min(paper.1), paper.0.max(paper.1)); + match o { + Orientation::Portrait => (w, h), + Orientation::Landscape => (h, w), + Orientation::Auto => { + if landscape_wanted { + (h, w) + } else { + (w, h) + } + } + } +} + +/// Place a page of display size `(dw, dh)` inside `cell` (sheet space), scaled by `s` +/// (centred), optionally rotated 90° (counter-clockwise). +fn place_in(page: usize, (dw, dh): (f64, f64), cell: [f64; 4], s: f64, rotate: bool) -> Placement { + let (cw, ch) = (cell[2] - cell[0], cell[3] - cell[1]); + let (pw, ph) = if rotate { (dh * s, dw * s) } else { (dw * s, dh * s) }; + let (x, y) = (cell[0] + (cw - pw) / 2.0, cell[1] + (ch - ph) / 2.0); + let matrix = if rotate { Matrix([0.0, s, -s, 0.0, x + pw, y]) } else { Matrix([s, 0.0, 0.0, s, x, y]) }; + Placement { page, matrix, clip: [0.0, 0.0, dw, dh] } +} + +fn fit_scale((dw, dh): (f64, f64), (cw, ch): (f64, f64)) -> f64 { + if dw <= 0.0 || dh <= 0.0 { 1.0 } else { (cw / dw).min(ch / dh) } +} + +/// Lay out the sheets for `settings` given each source page's display size. +pub fn layout(sizes: &[(f64, f64)], settings: &Settings) -> Result, PrintError> { + let pages: Vec = settings.pages.iter().copied().filter(|p| *p < sizes.len()).collect(); + if pages.is_empty() { + return Err(PrintError::NoPages); + } + let paper = settings.paper; + if !(paper.0 >= 72.0 && paper.1 >= 72.0 && paper.0.is_finite() && paper.1.is_finite()) { + return Err(PrintError::Invalid("invalid paper size".into())); + } + let mut sheets = Vec::new(); + match settings.layout { + Layout::Size(mode) => { + for &p in &pages { + let d = sizes[p]; + let size = oriented(paper, settings.orientation, d.0 > d.1); + let fit = fit_scale(d, (size.0 - 2.0 * MARGIN, size.1 - 2.0 * MARGIN)); + let s = match mode { + SizeMode::Fit => fit, + SizeMode::Actual => 1.0, + SizeMode::Shrink => fit.min(1.0), + SizeMode::Custom(pct) if pct.is_finite() && pct > 0.0 => pct / 100.0, + SizeMode::Custom(_) => return Err(PrintError::Invalid("the scale must be more than 0%".into())), + }; + sheets.push(Sheet { size, placed: vec![place_in(p, d, [0.0, 0.0, size.0, size.1], s, false)], ..Sheet::default() }); + } + } + Layout::Label { flip } => { + sheets.extend(layout_labels(sizes, &pages, paper, settings.orientation, settings, flip)); + } + Layout::Multiple { cols, rows, per_sheet, order, border, auto_rotate } => { + let cells = cols.checked_mul(rows).filter(|&n| n > 0 && n <= 256); + let Some(cells) = cells else { + return Err(PrintError::Invalid("invalid number of pages per sheet".into())); + }; + // The grid can have more cells than the pages asked for on each sheet; the rest stay blank. + let per_sheet = per_sheet.min(cells); + if per_sheet == 0 { + return Err(PrintError::Invalid("invalid number of pages per sheet".into())); + } + // A grid with more columns than rows wants a landscape sheet, and the reverse. + let first = pages.first().and_then(|&p| sizes.get(p)).copied().ok_or(PrintError::NoPages)?; + let page_landscape = first.0 > first.1; + let (gc, gr, sheet_landscape) = if cols == rows { + (cols, rows, page_landscape) + } else if settings.orientation == Orientation::Landscape || (settings.orientation == Orientation::Auto && cols < rows) { + // 2 per sheet (1 × 2 on portrait) prints side by side on landscape paper. + (rows, cols, true) + } else { + (cols, rows, false) + }; + let size = oriented(paper, settings.orientation, sheet_landscape); + let gap = 6.0; + let (aw, ah) = (size.0 - 2.0 * MARGIN, size.1 - 2.0 * MARGIN); + let (cw, ch) = ((aw - gap * (gc - 1) as f64) / gc as f64, (ah - gap * (gr - 1) as f64) / gr as f64); + if cw <= 0.0 || ch <= 0.0 { + return Err(PrintError::Invalid("the page grid does not fit on the paper".into())); + } + let sheet_count = pages.len().div_ceil(per_sheet); + for sheet_index in 0..sheet_count { + let mut sheet = Sheet { size, ..Sheet::default() }; + if order == PageOrder::CutStack { + // Marks align across every sheet, including unoccupied cells. Cut in the + // middle of each gutter; marks stay in the printable area, clear of cells. + for col in 1..gc { + let x = MARGIN + col as f64 * (cw + gap) - gap / 2.0; + sheet.lines.extend([[x, MARGIN, x, MARGIN + 8.0], [x, size.1 - MARGIN - 8.0, x, size.1 - MARGIN]]); + } + for row in 1..gr { + let y = size.1 - MARGIN - row as f64 * (ch + gap) + gap / 2.0; + sheet.lines.extend([[MARGIN, y, MARGIN + 8.0, y], [size.0 - MARGIN - 8.0, y, size.0 - MARGIN, y]]); + } + } + for k in 0..per_sheet { + // A cell's pile gets consecutive pages. Missing pages leave blank cells, + // rather than shifting later piles left on the final sheets. + let index = if order == PageOrder::CutStack { + k.checked_mul(sheet_count).and_then(|n| n.checked_add(sheet_index)) + } else { + sheet_index.checked_mul(per_sheet).and_then(|n| n.checked_add(k)) + }; + let Some(p) = index.and_then(|i| pages.get(i)).copied() else { continue }; + + let (col, row) = match order { + PageOrder::Horizontal | PageOrder::CutStack => (k % gc, k / gc), + PageOrder::HorizontalReversed => (gc - 1 - k % gc, k / gc), + PageOrder::Vertical => (k / gr, k % gr), + PageOrder::VerticalReversed => (gc - 1 - k / gr, k % gr), + }; + let x0 = MARGIN + col as f64 * (cw + gap); + let y1 = size.1 - MARGIN - row as f64 * (ch + gap); + let cell = [x0, y1 - ch, x0 + cw, y1]; + let d = sizes.get(p).copied().ok_or_else(|| PrintError::Invalid("page size is missing".into()))?; + if !(d.0.is_finite() && d.1.is_finite() && d.0 > 0.0 && d.1 > 0.0) { + return Err(PrintError::Invalid("invalid page size".into())); + } + let rotate = auto_rotate && (d.0 > d.1) != (cw > ch) && (d.0 - d.1).abs() > 1.0; + let s = fit_scale(if rotate { (d.1, d.0) } else { d }, (cw, ch)); + if !s.is_finite() || s <= 0.0 { + return Err(PrintError::Invalid("page size cannot be scaled to the grid".into())); + } + let pl = place_in(p, d, cell, s, rotate); + if border { + let b = pl.matrix.bbox(pl.clip); + sheet.borders.push(b); + } + sheet.placed.push(pl); + } + sheets.push(sheet); + } + } + Layout::Booklet { subset, binding } => { + // Pad to a multiple of 4 with blank pages (None). + let n4 = pages.len().div_ceil(4) * 4; + let at = |i: usize| pages.get(i).copied(); + let size = oriented(paper, Orientation::Landscape, true); + let half = (size.0 - 2.0 * MARGIN) / 2.0; + let cells = [[MARGIN, MARGIN, MARGIN + half, size.1 - MARGIN], [MARGIN + half, MARGIN, size.0 - MARGIN, size.1 - MARGIN]]; + for k in 0..n4 / 4 { + let front = (at(n4 - 1 - 2 * k), at(2 * k)); + let back = (at(2 * k + 1), at(n4 - 2 - 2 * k)); + let mut sides = Vec::new(); + if subset != BookletSubset::BackOnly { + sides.push(front); + } + if subset != BookletSubset::FrontOnly { + sides.push(back); + } + for (l, r) in sides { + let (l, r) = if binding == Binding::Right { (r, l) } else { (l, r) }; + let mut sheet = Sheet { size, ..Sheet::default() }; + for (p, cell) in [(l, cells[0]), (r, cells[1])] { + if let Some(p) = p { + let d = sizes[p]; + let s = fit_scale(d, (cell[2] - cell[0], cell[3] - cell[1])); + sheet.placed.push(place_in(p, d, cell, s, false)); + } + } + sheets.push(sheet); + } + } + } + Layout::Poster { scale, overlap, cut_marks } => { + if !(scale.is_finite() && scale > 0.0 && overlap.is_finite() && overlap >= 0.0) { + return Err(PrintError::Invalid("invalid tile scale or overlap".into())); + } + let s = scale / 100.0; + for &p in &pages { + let d = sizes[p]; + let size = oriented(paper, settings.orientation, d.0 > d.1); + // Each tile shows `step` of the scaled page plus `overlap` shared with the next. + let (tw, th) = (size.0 - 2.0 * MARGIN, size.1 - 2.0 * MARGIN); + if overlap * 2.0 >= tw.min(th) { + return Err(PrintError::Invalid("the overlap is larger than the tile".into())); + } + let (sw, sh) = (d.0 * s, d.1 * s); + let (stepx, stepy) = (tw - overlap, th - overlap); + let nx = (((sw - overlap) / stepx).ceil() as usize).max(1); + let ny = (((sh - overlap) / stepy).ceil() as usize).max(1); + // The counts saturate at usize::MAX for a huge scale, so their product can overflow. + if nx.checked_mul(ny).is_none_or(|n| n > 1024) { + return Err(PrintError::Invalid("the poster would need more than 1024 sheets".into())); + } + // Tiles from the top-left, row by row. + for ty in 0..ny { + for tx in 0..nx { + // The window into the scaled page (scaled display space, y up). + let wx0 = tx as f64 * stepx; + let wy1 = sh - ty as f64 * stepy; + let matrix = Matrix([s, 0.0, 0.0, s, MARGIN - wx0, size.1 - MARGIN - wy1]); + let clip = [wx0 / s, (wy1 - th) / s, (wx0 + tw) / s, wy1 / s]; + let mut sheet = Sheet { size, placed: vec![Placement { page: p, matrix, clip }], ..Sheet::default() }; + if cut_marks { + let (x0, y0, x1, y1) = (MARGIN, MARGIN, size.0 - MARGIN, size.1 - MARGIN); + for (x, y) in [(x0, y0), (x1, y0), (x0, y1), (x1, y1)] { + let dx = if x == x0 { -1.0 } else { 1.0 }; + let dy = if y == y0 { -1.0 } else { 1.0 }; + sheet.lines.push([x + dx * 2.0, y, x + dx * (MARGIN - 2.0), y]); + sheet.lines.push([x, y + dy * 2.0, x, y + dy * (MARGIN - 2.0)]); + } + } + sheets.push(sheet); + } + } + } + } + } + Ok(sheets) +} + +fn layout_labels(sizes: &[(f64, f64)], pages: &[usize], paper: (f64, f64), orientation: Orientation, settings: &Settings, flip: bool) -> Vec { + let size = oriented(paper, orientation, false); + let cell = [LABEL_MARGIN, LABEL_MARGIN, size.0 - LABEL_MARGIN, size.1 - LABEL_MARGIN]; + pages + .iter() + .map(|&p| { + let d = sizes[p]; + let crop = settings.crops.get(p).copied().flatten(); + let rect = crop + .map(|c| [c.rect[0].max(0.0), c.rect[1].max(0.0), c.rect[2].min(d.0), c.rect[3].min(d.1)]) + .filter(|r| r[2] - r[0] > 1.0 && r[3] - r[1] > 1.0) + .unwrap_or([0.0, 0.0, d.0, d.1]); + let (w, h) = (rect[2] - rect[0], rect[3] - rect[1]); + let turn = (w > h) != (cell[2] - cell[0] > cell[3] - cell[1]); + let mut quarter = match (turn, crop.is_some_and(|c| c.text_up)) { + (false, _) => 0, + (true, true) => 3, + (true, false) => 1, + }; + if flip { + quarter = (quarter + 2) % 4; + } + let s = if turn { + fit_scale((h, w), (cell[2] - cell[0], cell[3] - cell[1])) + } else { + fit_scale((w, h), (cell[2] - cell[0], cell[3] - cell[1])) + }; + Sheet { size, placed: vec![place_crop(p, rect, cell, s, quarter)], ..Sheet::default() } + }) + .collect() +} diff --git a/crates/print/src/spool.rs b/crates/print/src/spool.rs index 5ce3d9736b..a83da3ed26 100644 --- a/crates/print/src/spool.rs +++ b/crates/print/src/spool.rs @@ -143,14 +143,62 @@ pub(crate) fn printer_options_via_within( options } +/// The keys the macOS print dialog names a job's title and owner under. +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +const JOB_NAME_KEY: &str = "com.apple.print.JobInfo.PMJobName"; +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +const JOB_OWNER_KEY: &str = "com.apple.print.JobInfo.PMJobOwner"; + +/// `text` as a job option's value: `lp_args` passes on only printable ASCII, up to 64 bytes, +/// without `/ : = "`, and CUPS would take `'` and `\` as quoting and `{` as the start of a +/// collection, so all of these become `_`. Text with nothing left of it, such as a title in +/// another script, takes `fallback`: the job must still name something. So does a bare `true` or +/// `false`, which CUPS would send as a boolean, not the name a filter reads. +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +fn job_option_value(text: &str, fallback: &str) -> String { + let kept = |c: char| c.is_ascii_graphic() && !matches!(c, '/' | ':' | '=' | '"' | '\'' | '\\' | '{'); + let value: String = text.chars().take(64).map(|c| if kept(c) { c } else { '_' }).collect(); + if !text.chars().any(kept) || value.eq_ignore_ascii_case("true") || value.eq_ignore_ascii_case("false") { + return fallback.to_string(); + } + value +} + +/// `job` naming its title and `owner` as a job from the macOS print dialog does. `lp` sends +/// neither, and a driver's filter can depend on them: Kyocera's crashes without them, so nothing +/// prints, while the same file sent with both does (#5743). A value the caller set stands: the +/// last of a repeated option wins in CUPS, which compares their names without case. +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +pub(crate) fn with_job_info(job: &Job, owner: Option<&str>) -> Job { + let mut named = job.clone(); + for (key, value) in [(JOB_NAME_KEY, job.title.as_str()), (JOB_OWNER_KEY, owner.unwrap_or_default())] { + if !job.options.iter().any(|(k, _)| k.eq_ignore_ascii_case(key)) { + named.options.push((key.to_string(), job_option_value(value, "PdfCraft"))); + } + } + named +} + +/// The job as the system spooler takes it. +fn spooled(job: &Job) -> Job { + #[cfg(target_os = "macos")] + { + with_job_info(job, std::env::var("USER").ok().as_deref()) + } + #[cfg(not(target_os = "macos"))] + { + job.clone() + } +} + pub fn submit(pdf: &[u8], job: &Job) -> Result { - craft_print::submit(pdf, job).map_err(|e| crate::PrintError::Spool(e.to_string())) + craft_print::submit(pdf, &spooled(job)).map_err(|e| crate::PrintError::Spool(e.to_string())) } /// [`submit`], told `(sent, sheets)` as each sheet reaches the spooler (Windows; CUPS takes the /// whole PDF at once and reports nothing). pub fn submit_with_progress(pdf: &[u8], job: &Job, progress: &mut dyn FnMut(usize, usize)) -> Result { - craft_print::submit_with_progress(pdf, job, progress).map_err(|e| crate::PrintError::Spool(e.to_string())) + craft_print::submit_with_progress(pdf, &spooled(job), progress).map_err(|e| crate::PrintError::Spool(e.to_string())) } pub fn open_printer_preferences(printer: &str) -> Result<(), crate::PrintError> { diff --git a/crates/print/src/tests.rs b/crates/print/src/tests.rs index e6432d8aec..642af0b8c9 100644 --- a/crates/print/src/tests.rs +++ b/crates/print/src/tests.rs @@ -305,6 +305,107 @@ fn spooler_arguments_and_printer_list() { assert_eq!(lp_args(&Job::default())[0], "-n", "no -d: the default printer"); } +/// The job name and owner, read from the `lp` arguments, since `lp_args` drops a value it can't +/// pass on: the job must reach the spooler named, whatever its title holds (#5743). +#[test] +fn a_job_names_its_title_and_owner_to_the_spooler() { + let args_for = |title: &str, owner: Option<&str>| { + let job = Job { title: title.into(), ..Job::default() }; + lp_args(&spool::with_job_info(&job, owner)) + }; + let option = |args: &[String], key: &str| { + let prefix = format!("com.apple.print.JobInfo.{key}="); + args.iter().find_map(|a| a.strip_prefix(prefix.as_str()).map(str::to_string)) + }; + let args = args_for("memo.pdf", Some("sam")); + assert_eq!(option(&args, "PMJobName").as_deref(), Some("memo.pdf"), "{args:?}"); + assert_eq!(option(&args, "PMJobOwner").as_deref(), Some("sam"), "{args:?}"); + // The title keeps its place as `-t`, in full. + assert!(args_for("Q3 report: a/b=\"c\".pdf", None).windows(2).any(|w| w[0] == "-t" && w[1] == "Q3 report: a/b=\"c\".pdf")); + + let named = args_for("Q3 report: a/b=\"c\".pdf", None); + assert_eq!(option(&named, "PMJobName").as_deref(), Some("Q3_report__a_b__c_.pdf"), "what lp can't be given becomes _"); + assert_eq!(option(&named, "PMJobOwner").as_deref(), Some("PdfCraft"), "no owner known: the filter still gets one"); + + for nothing_left in ["", " ", "見積書", "/:=\""] { + assert_eq!(option(&args_for(nothing_left, Some("")), "PMJobName").as_deref(), Some("PdfCraft"), "{nothing_left:?}"); + } + assert_eq!(option(&args_for("見積書2.pdf", None), "PMJobName").as_deref(), Some("___2.pdf"), "a part of it is kept"); + let long = option(&args_for(&format!("a{}", "é".repeat(200)), None), "PMJobName").unwrap_or_default(); + assert_eq!(long.len(), 64, "cut to what lp_args takes, on a multi-byte title too: {long}"); + + // What CUPS would quote away or send as another type, as this Mac's libcups does. + for (title, name) in [("it's.pdf", "it_s.pdf"), (r"a\b.pdf", "a_b.pdf"), ("{draft}.pdf", "_draft}.pdf"), ("a{b", "a_b")] { + assert_eq!(option(&args_for(title, None), "PMJobName").as_deref(), Some(name), "{title:?}"); + } + for boolean in ["true", "TRUE", "false", "False"] { + assert_eq!(option(&args_for(boolean, Some(boolean)), "PMJobName").as_deref(), Some("PdfCraft"), "{boolean:?}"); + assert_eq!(option(&args_for(boolean, Some(boolean)), "PMJobOwner").as_deref(), Some("PdfCraft"), "{boolean:?}"); + } + assert_eq!(option(&args_for("true.pdf", None), "PMJobName").as_deref(), Some("true.pdf"), "only the bare word is a boolean"); +} + +/// A job's own values stand: CUPS keeps the last of a repeated option, ignoring the case of its +/// name, so a default after them would replace what a `doc_print` caller set (#5743). +#[test] +fn a_job_keeps_the_title_and_owner_it_already_sets() { + let values = |job: &Job, key: &str| -> Vec { + let found = spool::with_job_info(job, Some("sam")).options; + found.into_iter().filter(|(k, _)| k.eq_ignore_ascii_case(key)).map(|(_, v)| v).collect() + }; + let (name, owner) = ("com.apple.print.JobInfo.PMJobName", "com.apple.print.JobInfo.PMJobOwner"); + let job = Job { title: "memo.pdf".into(), options: vec![(name.to_string(), "Mine".to_string())], ..Job::default() }; + assert_eq!(values(&job, name), ["Mine"]); + assert_eq!(values(&job, owner), ["sam"], "the one it left out still gets its default"); + + let job = Job { title: "memo.pdf".into(), options: vec![(owner.to_ascii_uppercase(), "Theirs".to_string())], ..Job::default() }; + assert_eq!(values(&job, owner), ["Theirs"]); + assert_eq!(values(&job, name), ["memo.pdf"]); +} + +/// Both ways to submit reach `lp` with the job named. `lp` comes from `PATH`, which only a child +/// process can change here, so the test runs itself again with a stand-in that records its +/// arguments; no job reaches a real spooler (#5743). +#[cfg(target_os = "macos")] +#[test] +fn a_submitted_job_reaches_lp_named() { + const TEST: &str = "tests::a_submitted_job_reaches_lp_named"; + if let Ok(way) = std::env::var("PDFCRAFT_SUBMIT_TO_STAND_IN") { + let job = Job { title: "memo.pdf".into(), ..Job::default() }; + let sent = match way.as_str() { + "submit" => spool::submit(b"%PDF-1.7", &job), + _ => spool::submit_with_progress(b"%PDF-1.7", &job, &mut |_, _| {}), + }; + assert!(sent.is_ok(), "{sent:?}"); + return; + } + use std::os::unix::fs::PermissionsExt as _; + let dir = std::env::temp_dir().join(format!("pdfcraft-lp-stand-in-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("temp dir"); + let lp = dir.join("lp"); + let recorded = dir.join("args"); + let script = + format!("#!/bin/sh\nprintf '%s\\n' \"$@\" > '{}'\n/bin/cat > /dev/null\necho 'request id is stand-in-1 (1 file(s))'\n", recorded.display()); + std::fs::write(&lp, script).expect("stand-in"); + std::fs::set_permissions(&lp, std::fs::Permissions::from_mode(0o700)).expect("executable"); + for way in ["submit", "submit_with_progress"] { + let _ = std::fs::remove_file(&recorded); + let this = std::env::current_exe().expect("this test binary"); + let child = std::process::Command::new(this) + .args(["--exact", TEST, "--nocapture"]) + .env("PDFCRAFT_SUBMIT_TO_STAND_IN", way) + .env("PATH", &dir) + .env("USER", "sam") + .output() + .expect("run the test again"); + assert!(child.status.success(), "{way}: {}", String::from_utf8_lossy(&child.stdout)); + let args = std::fs::read_to_string(&recorded).expect("lp was run"); + assert!(args.lines().any(|a| a == "com.apple.print.JobInfo.PMJobName=memo.pdf"), "{way}: {args}"); + assert!(args.lines().any(|a| a == "com.apple.print.JobInfo.PMJobOwner=sam"), "{way}: {args}"); + } + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn lpstat_output_is_untranslated() { // A localized lpstat (here Polish) is unreadable to parse_lpstat... @@ -1062,3 +1163,85 @@ fn a_booklet_prints_both_sides_on_the_short_edge() { assert_eq!(booklet_duplex(BookletSubset::FrontOnly), spool::Duplex::Off); assert_eq!(booklet_duplex(BookletSubset::BackOnly), spool::Duplex::Off); } + +/// #3502: `/Contents` array pieces are joined with a space, matching the renderer, so a `%` +/// comment at the end of a piece without a line break continues through the first line of the +/// next piece in both `impose` and `text_reads_up`. +#[test] +fn contents_pieces_join_with_a_space_across_a_trailing_comment() { + let build_doc = |first_piece: &[u8]| { + let mut doc = Document::new_empty(); + let font = doc.add( + [ + (b"Type".to_vec(), Object::name("Font")), + (b"Subtype".to_vec(), Object::name("Type1")), + (b"BaseFont".to_vec(), Object::name("Helvetica")), + ] + .into_iter() + .collect::(), + ); + let s1 = doc.add(Stream::from_raw(Dict::new(), first_piece.to_vec())); + // Line 1 of s2 draws sideways ("HIDDEN") and 4 characters reading up; line 2 draws upright ("SHOWN"). + let s2 = doc.add(Stream::from_raw( + Dict::new(), + b"BT /F1 18 Tf 0 1 -1 0 50 50 Tm (HIDDEN) Tj ET\nBT /F1 18 Tf 1 0 0 1 20 100 Tm (SHOWN) Tj ET".to_vec(), + )); + let fonts: Dict = [(b"F1".to_vec(), Object::Ref(font))].into_iter().collect(); + let res: Dict = [(b"Font".to_vec(), Object::Dict(fonts))].into_iter().collect(); + let pages_root = doc.add(Object::Null); + let page = doc.add( + [ + (b"Type".to_vec(), Object::name("Page")), + (b"Parent".to_vec(), Object::Ref(pages_root)), + (b"MediaBox".to_vec(), Object::Array([0.0, 0.0, 200.0, 300.0].into_iter().map(Object::Real).collect())), + (b"Resources".to_vec(), Object::Dict(res)), + (b"Contents".to_vec(), Object::Array(vec![Object::Ref(s1), Object::Ref(s2)])), + ] + .into_iter() + .collect::(), + ); + doc.set( + pages_root, + Object::Dict( + [ + (b"Type".to_vec(), Object::name("Pages")), + (b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)])), + (b"Count".to_vec(), Object::Int(1)), + ] + .into_iter() + .collect(), + ), + ); + let cat = doc.add([(b"Type".to_vec(), Object::name("Catalog")), (b"Pages".to_vec(), Object::Ref(pages_root))].into_iter().collect::()); + doc.trailer_mut().set(b"Root".to_vec(), Object::Ref(cat)); + doc + }; + + let form_strings = |imposed: Vec| -> Vec> { + let out = Document::open(Arc::new(imposed)).unwrap(); + let page = &pdfcraft_model::pages(&out)[0]; + let res = out.resolve(page.dict.get(b"Resources").unwrap()).as_dict().cloned().unwrap(); + let xo = out.resolve(res.get(b"XObject").unwrap()).as_dict().cloned().unwrap(); + let form_obj = out.resolve(xo.get(b"P0").unwrap()); + let Object::Stream(form) = &*form_obj else { panic!("form stream") }; + let body = form.decoded().unwrap(); + pdfcraft_content::parse(&body) + .ops + .into_iter() + .filter(|op| op.op == b"Tj") + .filter_map(|op| op.operands.first().and_then(Object::as_string).map(|s| s.bytes.to_vec())) + .collect() + }; + + // Without a newline at the end of s1, `% comment` swallows line 1 of s2 (`(HIDDEN) Tj`). + let commented = build_doc(b"q Q % trailing comment"); + assert!(!text_reads_up(&commented, 0), "commented-out vertical text on piece 2's first line is not counted"); + let printed = impose(&commented, &settings(vec![0], Layout::Size(SizeMode::Actual))).unwrap(); + assert_eq!(form_strings(printed), [b"SHOWN".to_vec()], "impose does not un-comment piece 2's first line"); + + // Control: when s1 terminates the comment with a newline, line 1 of s2 is drawn and counted. + let with_newline = build_doc(b"q Q % trailing comment\n"); + assert!(text_reads_up(&with_newline, 0), "6 vertical vs 5 horizontal chars when line 1 is not commented out"); + let printed_ctrl = impose(&with_newline, &settings(vec![0], Layout::Size(SizeMode::Actual))).unwrap(); + assert_eq!(form_strings(printed_ctrl), [b"HIDDEN".to_vec(), b"SHOWN".to_vec()]); +} diff --git a/crates/redact/Cargo.toml b/crates/redact/Cargo.toml index 49593855e2..fe21d13b32 100644 --- a/crates/redact/Cargo.toml +++ b/crates/redact/Cargo.toml @@ -17,6 +17,8 @@ pdfcraft-forms = { workspace = true } pdfcraft-edit = { workspace = true } pdfcraft-annot = { workspace = true } pdfcraft-xfa = { workspace = true } +# CCITT Group 4 and JBIG2 encoding for cleared one-bit images. +pdfcraft-filters = { workspace = true } thiserror = { workspace = true } [dev-dependencies] diff --git a/crates/redact/README.md b/crates/redact/README.md index 34c9f4fe1f..db9f46b8c9 100644 --- a/crates/redact/README.md +++ b/crates/redact/README.md @@ -18,11 +18,29 @@ clear_marks(&mut doc, None)?; // remove marks without app more than a fifth of its size (or a point) both ways; zero-size text goes when its origin is inside; - **images**: fully covered → removed; partly covered → a copy with the covered pixels cleared - (8/16-bit and 1/2/4-bit images, image masks; Flate/LZW/RL/A85/AHx). Codecs PdfCraft can't - re-encode (DCT, JPX, JBIG2, CCITT) are removed whole (fail-closed); inline images under an - area are removed. Images with separate `/SMask` or `/Mask` data, and images whose data decodes - to more than twice their declared raster (plus a byte per row and 4 KiB), are removed whole; + (8/16-bit and 1/2/4-bit images, image masks; Flate/LZW/RL/A85/AHx, and CCITT fax or JBIG2 as + the last filter, decoded through `Document::decoded_image_within`, a JBIG2 image with its + `/JBIG2Globals`; `/DecodeParms` given by reference, numbers inside included, count). The copy + is Flate, or for a one-bit image of one component (a scan as a rule) CCITT Group 4, or JBIG2 + when it was JBIG2, whichever is smallest as stored, coded from the cleared samples; it refers to + no globals. Globals hold the shapes of every image that shares them (a scan's pages, often), + those of marks under the area included, so every other image that uses the globals of a removed + or replaced image is decoded and stored again in place without them, as a copy is (Flate, + Group 4 or JBIG2, whichever is smallest; `Report::images_restored`), and they leave the file on the full rewrite; one that can't be + decoded for certain refuses the redaction (`RedactError::SharedJbig2Globals`). Codecs redaction doesn't decode (DCT, JPX) are + removed whole (fail-closed), and so is a JBIG2 image whose page isn't its size (the renderer + draws the whole page into the image, so the rows cleared wouldn't be the ones shown) or of more + than one bit or component, and Group 3 on bytes (`/K` 0 or more, `/EncodedByteAlign`): PdfCraft's + renderer draws some of its layouts (the fill ending each end-of-line code on a byte) as other + rows than the decoder reads, so a mark over what is shown needn't cover what is cleared; inline images under an area are removed. Images with separate `/SMask` + or `/Mask` data, and images whose data decodes to more than three times their declared raster + and a byte per row, plus 4 KiB (ASCIIHex text broken into lines under Flate is a little over + twice; for a fax image's code, about a byte per pixel), are removed whole; cleared copies drop `/Alternates` so alternate pixels cannot survive; + A run that would decode more of its images than one redaction may (`pdfcraft_cos::ImageBudget`, + filters, decoders and pixels alike) is refused + and changes nothing: such an image can't be cleared, and removing it whole would take what no mark + covers with it. - **vectors**: covered paths are removed; partly covered paths and shadings are clipped so nothing paints inside the areas; - **form XObjects**: rewritten recursively into new objects (pages sharing the original keep it); @@ -98,5 +116,5 @@ Patterns are heuristic: dates, protocol identifiers and longer number runs are r `CodeSet::from_id("foia")?.overlay(&["(b)(6)"])` gives the overlay text for the picked codes. Custom code sets aren't supported yet. -Not yet: pattern locales, struct-tree/alt-text cleanup, metadata and hidden-information -sanitising (M8.4), re-encoding of DCT images (they are removed instead). +Not yet: pattern locales, struct-tree/alt-text cleanup, re-encoding of DCT images (they are removed +instead). diff --git a/crates/redact/src/image.rs b/crates/redact/src/image.rs index 601700f257..2e737e2baf 100644 --- a/crates/redact/src/image.rs +++ b/crates/redact/src/image.rs @@ -1,11 +1,14 @@ //! Clearing the pixels of an image under redaction regions. The image is decoded, every pixel //! whose footprint intersects a region is set to zero (no ink for image masks), and the result is -//! written as a new Flate image; the original object is untouched (other pages may use it). -//! Images whose codec PdfCraft can't decode (DCT, JPX, JBIG2, CCITT) return `None`, and the +//! written as a new image, Flate, or for a one-bit image of one component Group 4, or JBIG2 when it +//! was JBIG2, whichever is smallest; the original object is untouched (other pages may use it). +//! CCITT fax and JBIG2 images decode through `ImageBudget::decode`, but for Group 3 on +//! bytes, which PdfCraft's renderer may draw as other rows, and a JBIG2 page of other rows than the +//! image's. Images whose codec PdfCraft can't decode (DCT, JPX), and those, return `None`, and the //! caller removes the whole image instead; that is fail-closed. use pdfcraft_content::{Matrix, overlaps}; -use pdfcraft_cos::{Document, Object, Stream}; +use pdfcraft_cos::{Dict, Document, ImageBudget, Object, Stream}; /// Most pixels an image may have to be cleared in place (larger ones are removed). const MAX_PIXELS: u64 = 64 << 20; @@ -36,11 +39,11 @@ fn components(doc: &Document, s: &Stream) -> Option { /// `Ok(Some(copy))` with the covered pixels cleared, `Ok(None)` when no pixel intersects, /// `Err(())` when the image can't be cleared (the caller removes it). #[allow(clippy::result_unit_err)] -pub(crate) fn clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]]) -> Result, ()> { - decode_and_clear(doc, s, ctm, rects).ok_or(()) +pub(crate) fn clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]], budget: &mut ImageBudget) -> Result, ()> { + decode_and_clear(doc, s, ctm, rects, budget).ok_or(()) } -fn decode_and_clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]]) -> Option> { +fn decode_and_clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]], budget: &mut ImageBudget) -> Option> { // Separate masks can retain the original image data. if s.dict.contains(b"SMask") || s.dict.contains(b"Mask") { return None; @@ -51,15 +54,37 @@ fn decode_and_clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]] b"FlateDecode" | b"Fl" | b"LZWDecode" | b"LZW" | b"ASCII85Decode" | b"A85" | b"ASCIIHexDecode" | b"AHx" | b"RunLengthDecode" | b"RL" ) }; - let ok = match s.dict.get(b"Filter") { - None => true, - Some(Object::Name(n)) => supported(n), - Some(Object::Array(a)) => a.iter().all(|f| f.as_name().is_some_and(supported)), - _ => false, + // A CCITT fax or JBIG2 image (its last filter) decodes through `ImageBudget::decode`, + // after the filters before it, so a scan stored as Group 4 or JBIG2 is cleared like any other + // image. + let fax = |f: &Object| f.as_name().is_some_and(|n| matches!(n, b"CCITTFaxDecode" | b"CCF")); + let jbig2 = |f: &Object| f.as_name() == Some(b"JBIG2Decode".as_slice()); + let readable = |f: &Object| f.as_name().is_some_and(supported); + // `/Crypt` was undone when the object was loaded (ISO 32000-2 §7.4.10), and decoding leaves it + // out wherever it stands. + let crypt = |f: &Object| f.as_name() == Some(b"Crypt".as_slice()); + let (ok, is_fax, is_jbig2) = match s.dict.get(b"Filter") { + None => (true, false, false), + Some(f @ Object::Name(_)) => (readable(f) || crypt(f) || fax(f) || jbig2(f), fax(f), jbig2(f)), + Some(Object::Array(a)) => { + let chain: Vec<&Object> = a.iter().filter(|f| !crypt(f)).collect(); + match chain.split_last() { + Some((last, before)) if fax(last) || jbig2(last) => (before.iter().all(|f| readable(f)), fax(last), jbig2(last)), + _ => (chain.iter().all(|f| readable(f)), false, false), + } + } + _ => (false, false, false), }; if !ok { return None; } + // Group 3 on bytes is removed whole for now. The decoder reads it by its end-of-line codes + // when the data starts with one, but PdfCraft's renderer draws such rows (the fill ending + // each code on a byte) as other rows, so the pixels a mark covers on screen needn't be the + // ones cleared. + if is_fax && doc.fax_params(s).is_none_or(|p| p.k >= 0 && p.encoded_byte_align) { + return None; + } let (w, h) = (s.dict.int(b"Width")?, s.dict.int(b"Height")?); if w <= 0 || h <= 0 || (w as u64) * (h as u64) > MAX_PIXELS { return None; @@ -67,7 +92,8 @@ fn decode_and_clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]] let (w, h) = (w as usize, h as usize); let mask = matches!(s.dict.get(b"ImageMask"), Some(Object::Bool(true))); let (ncomp, bpc) = if mask { (1, 1) } else { (components(doc, s)?, s.dict.int(b"BitsPerComponent")? as usize) }; - if !matches!(bpc, 1 | 2 | 4 | 8 | 16) { + // JBIG2 codes one-bit images of one component (ISO 32000-2 §7.4.7). + if !matches!(bpc, 1 | 2 | 4 | 8 | 16) || (is_jbig2 && (ncomp, bpc) != (1, 1)) { return None; } // Every size comes from the file: multiply checked, so an overflowing product refuses the image @@ -78,17 +104,31 @@ fn decode_and_clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]] let row_bits = row.checked_mul(8)?; bytes.checked_mul(8)?; // Decode no further than the raster can need: a filter stage may legitimately produce up to - // twice it (ASCIIHex text under Flate) plus a PNG predictor's byte per row. Data past that - // can only be a decompression bomb; the image is then removed whole. - let allowance = bytes.checked_add(h)?.checked_mul(2)?.checked_add(4096)?; - let mut data = s.decoded_within(allowance).ok()?; + // three bytes for each of it and a PNG predictor's byte per row (ASCIIHex text under Flate, + // two digits a byte broken into lines or spaced), as the optimizer allows. A fax image's + // code, which the filters before its decoder produce, takes at most about a byte per pixel + // plus each row's end. Data past that can only be a decompression bomb; the image is then + // removed whole. + let allowance = if is_fax { + w.checked_mul(h)?.checked_add(h.checked_mul(4)?)?.checked_add(4096)? + } else { + bytes.checked_add(h)?.checked_mul(3)?.checked_add(4096)? + }; + // Through the document, which reads `/DecodeParms` given by reference (`Stream::decoded*` + // alone would skip such a predictor, and the cleared bytes would then miss the covered pixels), + // and refuses a JBIG2 page of another size than the image: the renderer draws the whole page + // into the image, so the rows cleared wouldn't be the ones a mark covers on screen. + let mut data = budget.decode(doc, s, allowance).ok()?; if data.len() < bytes { return None; } data.truncate(bytes); // Image masks: a sample of 0 paints unless /Decode is [1 0]; cleared samples must not paint. let clear_bit = if mask { - let inverted = s.dict.get(b"Decode").and_then(Object::as_array).and_then(|d| d.first()).and_then(Object::as_f64) == Some(1.0); + // The array, and its first number, given by reference too. + let decode = s.dict.get(b"Decode").map(|d| doc.resolve(d)); + let first = decode.as_deref().and_then(Object::as_array).and_then(|d| d.first()).map(|v| doc.resolve(v)); + let inverted = first.as_deref().and_then(Object::as_f64) == Some(1.0); !inverted } else { false @@ -132,5 +172,74 @@ fn decode_and_clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]] dict.remove(b"Length"); dict.remove(b"DecodeParms"); dict.remove(b"Alternates"); - Some(Some(Stream::flate(dict, &data))) + Some(Some(smallest(dict, &data, w, h, ncomp == 1 && bpc == 1, is_jbig2))) +} + +/// The cleared samples `data` of a `w` × `h` image as a stream with `dict`: Flate, or, for one bit +/// of one component (`bilevel`, a scan as a rule, which Flate stores several times larger), CCITT +/// Group 4, or JBIG2 when the image was JBIG2 (`jbig2`; JBIG2 is PDF 1.4, which such a file already +/// is), whichever is smallest as stored, dictionary included (a tiny image can lose by Group 4's +/// parameters). Both decode to `data`, cleared before it is coded, so nothing of the original's +/// code is carried over; Group 4 or JBIG2 that can't code the rows (too wide) is left out. +fn smallest(dict: Dict, data: &[u8], w: usize, h: usize, bilevel: bool, jbig2: bool) -> Stream { + let flate = Stream::flate(dict.clone(), data); + let Some((columns, rows)) = u32::try_from(w).ok().zip(u32::try_from(h).ok()).filter(|_| bilevel) else { return flate }; + let g4 = pdfcraft_filters::encode_g4(data, columns, rows).ok().map(|coded| { + let mut dict = dict.clone(); + let mut parms = Dict::new(); + parms.set(b"K".to_vec(), Object::Int(-1)); + parms.set(b"Columns".to_vec(), Object::Int(i64::from(columns))); + parms.set(b"Rows".to_vec(), Object::Int(i64::from(rows))); + dict.set(b"Filter".to_vec(), Object::name("CCITTFaxDecode")); + dict.set(b"DecodeParms".to_vec(), Object::Dict(parms)); + Stream::from_raw(dict, coded) + }); + let jbig = jbig2.then(|| pdfcraft_filters::encode_jbig2(data, columns, rows).ok()).flatten().map(|coded| { + let mut dict = dict.clone(); + dict.set(b"Filter".to_vec(), Object::name("JBIG2Decode")); + Stream::from_raw(dict, coded) + }); + [g4, jbig].into_iter().flatten().fold(flate, |best, s| if stored_len(&s) < stored_len(&best) { s } else { best }) +} + +/// The bytes stream `s` takes in a file: its data and its dictionary as written. +fn stored_len(s: &Stream) -> usize { + let mut dict = Vec::new(); + pdfcraft_cos::serialize(&Object::Dict(s.dict.clone()), &mut dict); + dict.len().saturating_add(s.raw.len()) +} + +/// JBIG2 image `s` stored again without its `/JBIG2Globals`: decoded through them, as it is drawn, +/// and written as the same samples, Flate, Group 4 or JBIG2 coded alone, whichever is smallest +/// (`smallest`), its other entries kept. `None` when it can't be decoded for certain (too large, +/// past what is left of `budget`, data that doesn't decode, a JBIG2 page of another size than the +/// image, or not a one-bit image of one component). Redaction does this to every image that +/// shares a redacted image's globals, since their symbol dictionary holds the shapes of marks drawn +/// under the area too. +pub(crate) fn without_globals(doc: &Document, s: &Stream, budget: &mut ImageBudget) -> Option { + let (w, h) = (s.dict.int(b"Width")?, s.dict.int(b"Height")?); + if w <= 0 || h <= 0 || (w as u64) * (h as u64) > MAX_PIXELS { + return None; + } + let (w, h) = (w as usize, h as usize); + let mask = matches!(s.dict.get(b"ImageMask"), Some(Object::Bool(true))); + // JBIG2 codes one-bit images of one component (ISO 32000-2 §7.4.7). + if !mask && (components(doc, s)?, s.dict.int(b"BitsPerComponent")?) != (1, 1) { + return None; + } + let bytes = w.div_ceil(8).checked_mul(h)?; + // As for `clear`: the filters before the JBIG2 decoder may give up to three bytes for each one + // of the raster and a byte a row. + let allowance = bytes.checked_add(h)?.checked_mul(3)?.checked_add(4096)?; + let mut data = budget.decode(doc, s, allowance).ok()?; + if data.len() < bytes { + return None; + } + data.truncate(bytes); + let mut dict = s.dict.clone(); + dict.remove(b"Length"); + // The globals are named in the parameters; the copy is coded alone, as `clear`'s are. + dict.remove(b"DecodeParms"); + dict.remove(b"Alternates"); + Some(smallest(dict, &data, w, h, true, true)) } diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index cef566042a..460085a320 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -21,11 +21,18 @@ use std::collections::{HashMap, HashSet}; use std::rc::Rc; use pdfcraft_content::{Matrix, Op, Pieces, contains, num, overlaps, serialize_ops, string}; -use pdfcraft_cos::{CosError, DecodeBudget, Dict, Document, MAX_PAGE_FORMS, ObjRef, Object, Stream}; +use pdfcraft_cos::{CosError, DecodeBudget, Dict, Document, ImageBudget, MAX_PAGE_FORMS, ObjRef, Object, Stream}; use crate::{RedactError, Report, image, page_streams, tags}; use pdfcraft_fonts::pdf::Metrics; +mod layers; +mod prune; + +use layers::strip_hidden_layers; +use prune::{clip_out, extgstate_font, prune_form_resources}; +pub(crate) use prune::{prune, prune_properties, resource_ops, unlink_doomed}; + /// How deep this pass follows form XObjects. A form past it is removed rather than walked, which /// is the fail-safe choice for a redaction -- marked content inside a form we cannot read would /// otherwise survive -- but it also removes whatever else that form drew, marked or not. So the @@ -98,6 +105,8 @@ pub(crate) struct Scope<'a> { pub rects: &'a [[f64; 4]], pub mode: Mode, pub report: &'a mut Report, + /// What the images it clears may spend, shared by all the pages of a run. + pub images: &'a mut ImageBudget, /// Sanitize: remove hidden text (invisible render modes, or wholly outside this page box). pub hidden_text: Option<[f64; 4]>, /// Sanitize: optional content groups that are off; content marked with them is removed. @@ -127,11 +136,12 @@ pub(crate) struct Scope<'a> { impl<'a> Scope<'a> { /// A scope whose form walk spends `budget`, what is left of the page's allowance after its /// content streams were decoded. - pub fn new(rects: &'a [[f64; 4]], mode: Mode, report: &'a mut Report, budget: DecodeBudget) -> Self { + pub fn new(rects: &'a [[f64; 4]], mode: Mode, report: &'a mut Report, budget: DecodeBudget, images: &'a mut ImageBudget) -> Self { Scope { rects, mode, report, + images, hidden_text: None, hidden_layers: HashSet::new(), layers_enabled: false, @@ -151,217 +161,6 @@ impl<'a> Scope<'a> { self.hidden_text.is_some() || self.layers_enabled } - /// Is this optional-content reference (an OCG or OCMD) hidden? - fn layer_hidden(&mut self, doc: &Document, o: &Object) -> bool { - if !self.layers_enabled { - return false; - } - let reference = o.as_ref(); - if let Some(r) = reference { - if let Some(hidden) = self.ocmd_cache.get(&r) { - return *hidden; - } - if self.hidden_layers.contains(&r) { - self.ocmd_cache.insert(r, true); - return true; - } - } - // Evaluate the policy against the same default configuration used to select hidden - // groups. A membership dictionary's default policy is AnyOn. - let d = doc.resolve(o); - let Some(d) = d.as_dict() else { return false }; - if d.name(b"Type") != Some(b"OCMD") { - return false; - } - let groups: Vec = match d.get(b"OCGs").map(|g| (*doc.resolve(g)).clone()) { - Some(Object::Array(a)) => a.iter().filter_map(Object::as_ref).collect(), - Some(_) => d.get(b"OCGs").and_then(Object::as_ref).into_iter().collect(), - None => Vec::new(), - }; - let fail_closed = || groups.iter().any(|g| self.hidden_layers.contains(g)); - let hidden = if let Some(expression) = d.get(b"VE") { - let visible = self.visibility_expression(doc, expression, 0, &mut HashSet::new(), &mut 4096); - match visible { - Some(visible) => !visible, - None => fail_closed() || self.expression_mentions_hidden(doc, expression, 0, &mut HashSet::new(), &mut 4096), - } - } else if groups.is_empty() { - false - } else { - let any_on = groups.iter().any(|g| !self.hidden_layers.contains(g)); - let all_on = groups.iter().all(|g| !self.hidden_layers.contains(g)); - let policy = d.get(b"P").map(|p| doc.resolve(p)).and_then(|p| p.as_name().map(|name| name.to_vec())); - match (d.get(b"P"), policy.as_deref()) { - (None, _) | (Some(_), Some(b"AnyOn")) => !any_on, - (Some(_), Some(b"AllOn")) => !all_on, - (Some(_), Some(b"AnyOff")) => all_on, - (Some(_), Some(b"AllOff")) => any_on, - // An invalid policy must not make a candidate hidden block survive sanitization. - _ => fail_closed(), - } - }; - if !hidden { - self.retained_off_layers.extend(groups.iter().filter(|g| self.hidden_layers.contains(g)).copied()); - if let Some(expression) = d.get(b"VE") { - let mut referenced = HashSet::new(); - if Self::collect_hidden_expression_groups(doc, expression, &self.hidden_layers, 0, &mut HashSet::new(), &mut 4096, &mut referenced) { - self.retained_off_layers.extend(referenced); - } else { - // If an expression cannot be fully walked, preserve every off group so that - // the content we kept does not change visibility after configuration cleanup. - self.retained_off_layers.extend(self.hidden_layers.iter().copied()); - } - } - } - if let Some(r) = reference { - self.ocmd_cache.insert(r, hidden); - } - hidden - } - - fn xobject_layer_hidden(&mut self, doc: &Document, xobject: ObjRef, oc: &Object) -> bool { - if oc.as_ref().is_some() { - return self.layer_hidden(doc, oc); - } - if let Some(hidden) = self.xobject_oc_cache.get(&xobject) { - return *hidden; - } - let hidden = self.layer_hidden(doc, oc); - self.xobject_oc_cache.insert(xobject, hidden); - hidden - } - - fn collect_hidden_expression_groups( - doc: &Document, - expression: &Object, - hidden_layers: &HashSet, - depth: usize, - path: &mut HashSet, - budget: &mut usize, - found: &mut HashSet, - ) -> bool { - if depth > 64 || *budget == 0 { - return false; - } - *budget -= 1; - let reference = expression.as_ref(); - if let Some(r) = reference - && !path.insert(r) - { - return false; - } - let resolved = doc.resolve(expression); - let complete = match &*resolved { - Object::Dict(d) if d.name(b"Type") == Some(b"OCG") => { - if let Some(r) = reference - && hidden_layers.contains(&r) - { - found.insert(r); - } - reference.is_some() - } - Object::Array(items) => { - let Some(Object::Name(operator)) = items.first() else { return false }; - let operands = match operator.as_slice() { - b"And" | b"Or" if (2..=4097).contains(&items.len()) => items.get(1..), - b"Not" if items.len() == 2 => items.get(1..), - _ => None, - }; - operands.is_some_and(|operands| { - operands.iter().all(|item| Self::collect_hidden_expression_groups(doc, item, hidden_layers, depth + 1, path, budget, found)) - }) - } - _ => false, - }; - if let Some(r) = reference { - path.remove(&r); - } - complete - } - - /// Evaluate an optional-content visibility expression (`/VE`) against the default config. - /// Malformed, cyclic, or excessive expressions return `None` so the caller can fail closed. - fn visibility_expression( - &self, - doc: &Document, - expression: &Object, - depth: usize, - path: &mut HashSet, - budget: &mut usize, - ) -> Option { - if depth > 64 || *budget == 0 { - return None; - } - *budget -= 1; - let reference = expression.as_ref(); - if let Some(r) = reference - && !path.insert(r) - { - return None; - } - let resolved = doc.resolve(expression); - let result = match &*resolved { - Object::Dict(d) if d.name(b"Type") == Some(b"OCG") => reference.map(|r| !self.hidden_layers.contains(&r)), - Object::Array(items) if (2..=4097).contains(&items.len()) => { - let operator = match items.first() { - Some(Object::Name(n)) => n.as_slice(), - _ => return None, - }; - match operator { - b"And" if items.len() >= 2 => { - let mut visible = true; - for item in items.iter().skip(1) { - visible &= self.visibility_expression(doc, item, depth + 1, path, budget)?; - } - Some(visible) - } - b"Or" if items.len() >= 2 => { - let mut visible = false; - for item in items.iter().skip(1) { - visible |= self.visibility_expression(doc, item, depth + 1, path, budget)?; - } - Some(visible) - } - b"Not" if items.len() == 2 => Some(!self.visibility_expression(doc, &items[1], depth + 1, path, budget)?), - _ => None, - } - } - _ => None, - }; - if let Some(r) = reference { - path.remove(&r); - } - result - } - - /// Conservatively find hidden groups in an expression that could not be evaluated. Unknown - /// syntax, cycles, and exhausted budgets count as a possible hidden reference. - fn expression_mentions_hidden(&self, doc: &Document, expression: &Object, depth: usize, path: &mut HashSet, budget: &mut usize) -> bool { - if depth > 64 || *budget == 0 { - return true; - } - *budget -= 1; - if let Some(r) = expression.as_ref() { - if self.hidden_layers.contains(&r) { - return true; - } - if !path.insert(r) { - return true; - } - } - let resolved = doc.resolve(expression); - let found = match &*resolved { - Object::Array(items) => items.iter().any(|item| self.expression_mentions_hidden(doc, item, depth + 1, path, budget)), - Object::Dict(d) if d.name(b"Type") == Some(b"OCG") => expression.as_ref().is_none_or(|r| self.hidden_layers.contains(&r)), - Object::Dict(_) => true, - _ => false, - }; - if let Some(r) = expression.as_ref() { - path.remove(&r); - } - found - } - fn hits(&self, b: [f64; 4]) -> bool { self.rects.iter().any(|r| overlaps(*r, b, 0.0)) } @@ -418,359 +217,6 @@ fn fresh_name(scope: &mut Scope<'_>, xobjects: &Dict) -> Vec { } } -/// Drop every `replaced` resource name whose `Do` no longer appears in the rewritten `content`, -/// so a full-rewrite save stops reaching the original image or form it named. A name that some -/// surviving `Do` still draws (the same image shown again outside a mark) is kept. -pub(crate) fn prune(xo: &mut Dict, replaced: &[Vec], content: &[Vec]) { - if replaced.is_empty() { - return; - } - let joined = Pieces::join(content); - let ops = joined.parse().ops; - let still_drawn = |n: &[u8]| ops.iter().any(|op| op.is("Do") && op.last_name() == Some(n)); - for n in replaced { - if !still_drawn(n) { - xo.remove(n); - } - } -} - -/// Whether `content` draws the XObject `name` of the resource dictionary `xo`: directly, or -/// through a form that has no /Resources of its own (such a form draws through its caller's). -/// `None` when that cannot be told (a form that will not decode, or nesting past the limit). -fn content_draws(doc: &Document, content: &[Vec], xo: &Dict, name: &[u8], depth: usize, seen: &mut HashSet) -> Option { - let joined = Pieces::join(content); - let ops = joined.parse().ops; - for op in ops.iter().filter(|op| op.is("Do")) { - let Some(n) = op.last_name() else { continue }; - if n == name { - return Some(true); - } - let Some(r) = xo.get(n).and_then(Object::as_ref) else { continue }; - let obj = doc.get(r); - let Object::Stream(s) = &*obj else { continue }; - if s.dict.name(b"Subtype") != Some(b"Form") || s.dict.contains(b"Resources") || !seen.insert(r) { - continue; - } - if depth >= MAX_DEPTH { - return None; - } - let Ok(data) = s.decoded() else { return None }; - match content_draws(doc, std::slice::from_ref(&data), xo, name, depth + 1, seen) { - Some(false) => {} - other => return other, - } - } - Some(false) -} - -/// Unlink the XObjects a rewrite dropped or replaced (`doomed`) from every page-tree resource -/// dictionary that still names them. The rewritten page has its own pruned /Resources, but the -/// originals stay reachable, and so stay in a full-rewrite save, when: -/// - the page inherited its /Resources from a /Pages node, which still lists them; -/// - several pages share one resource dictionary, and another page still refers to it; -/// - a form without /Resources drew them through the page's dictionary. -/// -/// An entry goes unless a page that draws through that dictionary still draws the name (the same -/// image shown on another page, or again outside a mark). A dictionary that loses an entry is -/// replaced by a private copy on its node, so a dictionary object shared with other nodes is -/// left as it is for them. Retired properties are likewise unlinked only when none of the -/// holder's pages or inheriting forms names them. Fails closed when use cannot be determined. -pub(crate) fn unlink_doomed(doc: &mut Document, doomed: &[ObjRef], retired_properties: &[Object]) -> Result<(), RedactError> { - crate::type3::unlink_type3(doc)?; - if doomed.is_empty() && retired_properties.is_empty() { - return Ok(()); - } - let doomed: HashSet = doomed.iter().copied().collect(); - let Some(top) = doc.root().and_then(|root| doc.get(root).as_dict().and_then(|d| d.reference(b"Pages"))) else { return Ok(()) }; - // Every page-tree node with a /Resources of its own, and the pages that draw through it - // (the nearest such node above a page, or the page itself). Cycles are cut as in - // `pdfcraft_model::pages`. - let mut holders: Vec<(ObjRef, Vec)> = Vec::new(); - let mut seen = HashSet::new(); - let mut stack: Vec<(ObjRef, Option)> = vec![(top, None)]; - while let Some((node, mut holder)) = stack.pop() { - if !seen.insert(node) || seen.len() > 1_000_000 { - continue; - } - let obj = doc.get(node); - let Some(d) = obj.as_dict() else { continue }; - if d.contains(b"Resources") { - holders.push((node, Vec::new())); - holder = Some(holders.len() - 1); - } - let is_pages = d.name(b"Type") == Some(b"Pages") || (d.contains(b"Kids") && d.name(b"Type") != Some(b"Page")); - if is_pages { - let kids = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default(); - stack.extend(kids.iter().filter_map(Object::as_ref).map(|k| (k, holder))); - } else if let Some(h) = holder.and_then(|i| holders.get_mut(i)) { - h.1.push(node); - } - } - let index: HashMap = pdfcraft_model::pages(doc).iter().enumerate().map(|(i, p)| (p.obj, i)).collect(); - let holder_nodes: Vec = holders.iter().map(|(node, _)| *node).collect(); - for (node, users) in holders { - let Some(mut res) = doc.get(node).as_dict().and_then(|d| d.get(b"Resources")).and_then(|r| doc.resolve(r).as_dict().cloned()) else { - continue; - }; - let mut xo = res_dict(doc, &res, b"XObject"); - let names: Vec> = xo.iter().filter(|(_, v)| v.as_ref().is_some_and(|r| doomed.contains(&r))).map(|(n, _)| n.to_vec()).collect(); - let properties = res_dict(doc, &res, b"Properties"); - let property_names: Vec> = properties.iter().filter(|(_, v)| retired_properties.contains(v)).map(|(n, _)| n.to_vec()).collect(); - if names.is_empty() && property_names.is_empty() { - continue; - } - let mut contents = Vec::with_capacity(users.len()); - for user in &users { - let page = doc.get(*user).as_dict().cloned().unwrap_or_default(); - // A page whose content cannot be read may still draw the name: fail closed. - contents.push(page_streams(doc, &page, index.get(user).copied().unwrap_or(0))?.1); - } - let mut pruned = false; - for n in names { - let mut drawn = Some(false); - for content in &contents { - drawn = content_draws(doc, content, &xo, &n, 0, &mut HashSet::new()); - if drawn != Some(false) { - break; - } - } - match drawn { - Some(true) => {} - Some(false) => { - xo.remove(&n); - pruned = true; - } - None => return Err(RedactError::Residue(1)), - } - } - if pruned { - res.set(b"XObject".to_vec(), Object::Dict(xo)); - } - pruned |= prune_retired_properties(doc, &mut res, &property_names, &contents)?; - if pruned { - doc.update_dict(node, |d| d.set(b"Resources".to_vec(), Object::Dict(res)))?; - } - } - // A surviving form can own the same old resource dictionary as a rewritten page. Its - // unused descriptions and doomed XObjects remain reachable too, even after every page - // holder is cleaned. - unlink_from_forms(doc, &holder_nodes, &doomed, retired_properties)?; - Ok(()) -} - -/// Retire only properties removed from a rewritten scope, preserving names still used by any -/// content sharing this holder. Different pages' streams must never be concatenated together. -fn prune_retired_properties(doc: &Document, res: &mut Dict, names: &[Vec], contents: &[Vec>]) -> Result { - if names.is_empty() { - return Ok(false); - } - let mut used = HashSet::new(); - for content in contents { - for op in resource_ops(doc, res, content)? { - if matches!(op.op.as_slice(), b"BDC" | b"DP") - && let Some(name) = op.name(1) - { - used.insert(name.to_vec()); - } - } - } - let mut properties = res_dict(doc, res, b"Properties"); - let mut pruned = false; - for name in names { - if !used.contains(name) { - pruned |= properties.remove(name).is_some(); - } - } - if pruned { - if properties.is_empty() { - res.remove(b"Properties"); - } else { - res.set(b"Properties".to_vec(), Object::Dict(properties)); - } - } - Ok(pruned) -} - -/// Unlink retired properties and `doomed` XObjects from the resources of the forms reachable from -/// the page-tree `holders`, each unless the form's own content (or a form inheriting its -/// resources) still uses the name. Fails closed when that cannot be determined. -fn unlink_from_forms(doc: &mut Document, holders: &[ObjRef], doomed: &HashSet, retired: &[Object]) -> Result<(), CosError> { - if retired.is_empty() && doomed.is_empty() { - return Ok(()); - } - let mut pending: Vec = holders - .iter() - .filter_map(|r| doc.get(*r).as_dict().and_then(|d| d.get(b"Resources")).and_then(|r| doc.resolve(r).as_dict().cloned())) - .collect(); - let mut seen = HashSet::new(); - while let Some(resources) = pending.pop() { - for (_, value) in res_dict(doc, &resources, b"XObject").iter() { - let Some(reference) = value.as_ref() else { continue }; - if !seen.insert(reference) { - continue; - } - if seen.len() > 1_000_000 { - return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect shared form resources".into() }); - } - let object = doc.get(reference); - let Object::Stream(form) = &*object else { continue }; - if form.dict.name(b"Subtype") != Some(b"Form") { - continue; - } - let Some(mut res) = form.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()) else { continue }; - let names: Vec> = - res_dict(doc, &res, b"Properties").iter().filter(|(_, value)| retired.contains(value)).map(|(name, _)| name.to_vec()).collect(); - let mut xo = res_dict(doc, &res, b"XObject"); - let doomed_names: Vec> = - xo.iter().filter(|(_, v)| v.as_ref().is_some_and(|r| doomed.contains(&r))).map(|(n, _)| n.to_vec()).collect(); - if names.is_empty() && doomed_names.is_empty() { - pending.push(res); - continue; - } - let data = form.decoded()?; - let mut pruned = prune_doomed_xobjects(doc, std::slice::from_ref(&data), &mut xo, &doomed_names)?; - if pruned { - res.set(b"XObject".to_vec(), Object::Dict(xo)); - } - pruned |= prune_retired_properties(doc, &mut res, &names, &[vec![data]])?; - if pruned { - doc.update_dict(reference, |d| d.set(b"Resources".to_vec(), Object::Dict(res.clone())))?; - } - pending.push(res); - } - } - Ok(()) -} - -fn prune_doomed_xobjects(doc: &Document, content: &[Vec], xo: &mut Dict, doomed_names: &[Vec]) -> Result { - let mut pruned = false; - for name in doomed_names { - match content_draws(doc, content, xo, name, 0, &mut HashSet::new()) { - Some(true) => {} - Some(false) => pruned |= xo.remove(name).is_some(), - None => return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect shared form resources".into() }), - } - } - Ok(pruned) -} - -/// The clip that hides everything inside each region: one even-odd clip per region (a big -/// rectangle with the region cut out), in the current user space. Returns `None` when the CTM -/// can't be inverted (nothing is painted then anyway). -fn clip_out(rects: &[[f64; 4]], ctm: &Matrix, around: [f64; 4]) -> Option> { - let inv = ctm.invert()?; - let mut ops = Vec::new(); - let big = [around[0] - 10.0, around[1] - 10.0, around[2] + 10.0, around[3] + 10.0]; - for r in rects.iter().filter(|r| overlaps(**r, around, 0.0)) { - let outer = [(big[0], big[1]), (big[2], big[1]), (big[2], big[3]), (big[0], big[3])]; - let inner = [(r[0], r[1]), (r[0], r[3]), (r[2], r[3]), (r[2], r[1])]; - for poly in [outer, inner] { - for (k, (x, y)) in poly.iter().enumerate() { - let (u, v) = inv.apply(*x, *y); - ops.push(Op::new(if k == 0 { "m" } else { "l" }, vec![num(u), num(v)])); - } - ops.push(Op::new("h", vec![])); - } - ops.push(Op::new("W*", vec![])); - ops.push(Op::new("n", vec![])); - } - Some(ops) -} - -pub(crate) fn resource_ops(doc: &Document, res: &Dict, content: &[Vec]) -> Result, CosError> { - let parsed = Pieces::join(content).parse(); - if parsed.skipped > 0 || parsed.ops.len() > 2_000_000 { - return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect property resources".into() }); - } - let mut ops = parsed.ops; - let xobjects = res_dict(doc, res, b"XObject"); - let mut seen = HashSet::new(); - let mut cursor = 0; - while cursor < ops.len() { - let op = &ops[cursor]; - if op.is("Do") - && let Some(r) = op.last_name().and_then(|n| xobjects.get(n)).and_then(Object::as_ref) - && seen.insert(r) - && let Object::Stream(s) = &*doc.get(r) - && s.dict.name(b"Subtype") == Some(b"Form") - && s.dict.get(b"Resources").is_none_or(|r| doc.resolve(r).as_dict().is_none()) - { - let parsed = pdfcraft_content::parse(&s.decoded()?); - if seen.len() > 2_000_000 || ops.len().saturating_add(parsed.ops.len()) > 2_000_000 || parsed.skipped > 0 { - return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect inherited form resources".into() }); - } - ops.extend(parsed.ops); - } - cursor += 1; - } - Ok(ops) -} - -pub(crate) fn prune_properties(doc: &Document, res: &mut Dict, content: &[Vec]) -> Result, CosError> { - let mut properties = res_dict(doc, res, b"Properties"); - if properties.is_empty() { - return Ok(Vec::new()); - } - let mut retired = Vec::new(); - let ops = resource_ops(doc, res, content)?; - let names: Vec> = properties.iter().map(|(n, _)| n.to_vec()).collect(); - for name in names { - if !ops.iter().any(|op| matches!(op.op.as_slice(), b"BDC" | b"DP") && op.name(1) == Some(&name)) - && let Some(value) = properties.remove(&name) - { - retired.push(value); - } - } - if properties.is_empty() { - res.remove(b"Properties"); - } else { - res.set(b"Properties".to_vec(), Object::Dict(properties)); - } - Ok(retired) -} - -fn prune_form_resources(doc: &Document, res: &mut Dict, content: &[Vec]) -> Result<(), CosError> { - let ops = resource_ops(doc, res, content)?; - let mut xo = res_dict(doc, res, b"XObject"); - let names: Vec> = xo.iter().map(|(n, _)| n.to_vec()).collect(); - for name in names { - if !ops.iter().any(|op| op.is("Do") && op.last_name() == Some(&name)) { - xo.remove(&name); - } - } - res.set(b"XObject".to_vec(), Object::Dict(xo)); - Ok(()) -} - -/// The font, writing direction and size an ExtGState's `/Font [font size]` entry sets, as `Tf` -/// would (ISO 32000-2 §8.4.5); `None` when `name` sets no font. The metrics are cached with the -/// fonts `Tf` names, under a key no resource name can take (a name cannot hold a null byte). -fn extgstate_font( - doc: &Document, - scope: &mut Scope<'_>, - ext_gstates: &Dict, - name: &[u8], - fallback: &Rc, -) -> Option<(Rc, bool, f64)> { - let state = doc.resolve(ext_gstates.get(name)?).as_dict().cloned()?; - let entry = doc.resolve(state.get(b"Font")?).as_array().cloned()?; - let [font, size] = entry.as_slice() else { return None }; - let size = doc.resolve(size).as_f64()?; - let font_dict = doc.resolve(font).as_dict().cloned(); - let vertical = font_dict.as_ref().is_some_and(|d| is_vertical(doc, d)); - let key = [b"\0".as_slice(), name].concat(); - let metrics = match scope.fonts.get(&key) { - Some(m) => m.clone(), - None => { - let m = font_dict.as_ref().map(|d| Rc::new(Metrics::from_dict(doc, d))).unwrap_or_else(|| fallback.clone()); - scope.fonts.insert(key, m.clone()); - m - } - }; - Some((metrics, vertical, size)) -} - /// One glyph of a shown string. struct Glyph { bytes: std::ops::Range, @@ -799,32 +245,21 @@ pub(crate) fn process(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec< /// [`process`] from graphics state `gs`: a form XObject starts with its caller's, text state /// included (ISO 32000-2 §8.10.1), so text it shows without setting a font of its own is placed /// with the caller's font, size and spacing, as the renderer places it. -fn process_from(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec], resources: &Dict, mut gs: Gs) -> Output { - let mut out = Output::default(); - let fallback = Rc::new(Metrics::fallback()); - let mut stack: Vec = Vec::new(); - let (mut tm, mut tlm) = (Matrix::IDENTITY, Matrix::IDENTITY); - let fonts_res = res_dict(doc, resources, b"Font"); - let ext_gstates = res_dict(doc, resources, b"ExtGState"); - let mut xobjects = res_dict(doc, resources, b"XObject"); - // The current path: its operators, its bounding box in user space, and whether it clips. - let mut path: Vec = Vec::new(); - let mut path_box: Option<[f64; 4]> = None; - let mut clip = false; - let mut forced_change = false; - +fn process_from(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec], resources: &Dict, gs: Gs) -> Output { + let mut walk = Walk::new(doc, resources, gs); // The streams are one content stream in pieces, which may be split between any two tokens // (ISO 32000-2 §7.8.2): an operator's operands can end one piece and the operator start the // next. Parse them joined, then give each operator back to the piece its keyword is in. let joined = Pieces::join(streams); let mut parsed = joined.parse(); - out.ambiguous = parsed.ambiguous_images > 0; + walk.out.ambiguous = parsed.ambiguous_images > 0; // A rewritten piece loses its comments, so keep where they began and ended: a comment that // ran on into the next piece still hides that piece's first line. let comment_edges = joined.comment_edges(&parsed); // Layer removal also changes enclosing replacement spans, so remember the operators before // any cleanup, not just the survivors of the layer pass. let original = parsed.ops.clone(); + let mut forced_change = false; if scope.layers_enabled { let props = res_dict(doc, resources, b"Properties"); let (kept, removed) = strip_hidden_layers(doc, scope, parsed.ops, &props); @@ -834,12 +269,32 @@ fn process_from(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec], forced_change = true; } } - let mut pieces: Vec> = streams.iter().map(|_| Vec::new()).collect(); - let mut rewrite: Vec = vec![forced_change; streams.len()]; - for op in parsed.ops { + let (pieces, rewrite) = split_into_pieces(&joined, parsed.ops, streams.len(), forced_change); + let mut processed = Vec::new(); + for (piece, changed) in pieces.into_iter().zip(rewrite) { + let mut done = Piece { ops: Vec::with_capacity(piece.len()), changed }; + for op in piece { + walk.step(doc, scope, resources, op, &mut done); + } + if !walk.path.is_empty() { + done.ops.append(&mut walk.path); + } + processed.push((done.ops, done.changed)); + } + if scope.mode == Mode::Apply { + clean_replacements(doc, resources, &original, &mut processed, scope.report); + } + walk.out.streams = processed.into_iter().zip(comment_edges).map(|((ops, changed), edges)| changed.then(|| piece_bytes(&ops, edges))).collect(); + walk.out +} + +/// Gives each operator to the piece its keyword is in. An operator split across pieces is written +/// whole into its keyword's piece, so every piece it spans is rewritten. +fn split_into_pieces(joined: &Pieces, ops: Vec, count: usize, forced_change: bool) -> (Vec>, Vec) { + let mut pieces: Vec> = (0..count).map(|_| Vec::new()).collect(); + let mut rewrite: Vec = vec![forced_change; count]; + for op in ops { let (first, last) = joined.pieces_of(&op); - // An operator split across pieces is written whole into its keyword's piece, so every - // piece it spans is rewritten. if first < last { for r in rewrite.iter_mut().take(last + 1).skip(first) { *r = true; @@ -849,228 +304,300 @@ fn process_from(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec], p.push(op); } } + (pieces, rewrite) +} - let mut processed = Vec::new(); - for (piece, mut changed) in pieces.into_iter().zip(rewrite) { - let mut ops: Vec = Vec::with_capacity(piece.len()); - for op in piece { - let o = op.op.as_slice(); - // Path construction. - if matches!(o, b"m" | b"l" | b"c" | b"v" | b"y" | b"h" | b"re" | b"W" | b"W*") { - let pts: Vec<(f64, f64)> = match o { - b"re" => op.nums::<4>().map(|[x, y, w, h]| vec![(x, y), (x + w, y), (x, y + h), (x + w, y + h)]).unwrap_or_default(), - b"h" | b"W" | b"W*" => Vec::new(), - _ => op.operands.iter().filter_map(Object::as_f64).collect::>().as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect(), - }; - for (x, y) in pts { - let (u, v) = gs.ctm.apply(x, y); - path_box = Some(match path_box { - None => [u, v, u, v], - Some(b) => [b[0].min(u), b[1].min(v), b[2].max(u), b[3].max(v)], - }); +/// A rewritten piece's bytes. `(comment_in, comment_out)` say whether the piece before ended +/// inside a comment, and whether this one did. +fn piece_bytes(ops: &[Op], (comment_in, comment_out): (bool, bool)) -> Vec { + // The piece before still ends inside a comment: end it before the first operator. + let mut bytes = if comment_in { b"\n".to_vec() } else { Vec::new() }; + bytes.extend(serialize_ops(ops)); + // The piece ended inside a comment that hid the start of the next one: keep it open. + if comment_out { + bytes.push(b'%'); + } + bytes +} + +/// The operators written for one piece of a scope's content, and whether they differ from the source. +struct Piece { + ops: Vec, + changed: bool, +} + +/// The interpreter state carried across the operators of a scope. +struct Walk { + gs: Gs, + /// With each saved state, the text and text line matrices: inside a text object `q`/`Q` + /// save and restore them too (ISO 32000-2 §9.4.1 as corrected by the PDF Association's + /// errata, issue 368), as the renderer does. + stack: Vec<(Gs, Matrix, Matrix)>, + tm: Matrix, + tlm: Matrix, + fonts_res: Dict, + ext_gstates: Dict, + xobjects: Dict, + fallback: Rc, + /// The current path: its operators, its bounding box in user space, and whether it clips. + path: Vec, + path_box: Option<[f64; 4]>, + clip: bool, + out: Output, +} + +impl Walk { + fn new(doc: &Document, resources: &Dict, gs: Gs) -> Self { + Self { + gs, + stack: Vec::new(), + tm: Matrix::IDENTITY, + tlm: Matrix::IDENTITY, + fonts_res: res_dict(doc, resources, b"Font"), + ext_gstates: res_dict(doc, resources, b"ExtGState"), + xobjects: res_dict(doc, resources, b"XObject"), + fallback: Rc::new(Metrics::fallback()), + path: Vec::new(), + path_box: None, + clip: false, + out: Output::default(), + } + } + + /// Interprets one operator, writing what replaces it to `piece`. + fn step(&mut self, doc: &mut Document, scope: &mut Scope<'_>, resources: &Dict, op: Op, piece: &mut Piece) { + let o = op.op.as_slice(); + if matches!(o, b"m" | b"l" | b"c" | b"v" | b"y" | b"h" | b"re" | b"W" | b"W*") { + self.construct_path(op); + return; + } + if matches!(o, b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" | b"n") { + self.paint_path(scope, op, piece); + return; + } + if !self.path.is_empty() { + // A path not ended by a painting operator: pass it through. + piece.ops.append(&mut self.path); + self.path_box = None; + self.clip = false; + } + let kept = match o { + b"q" | b"Q" | b"cm" | b"Tf" | b"gs" => { + self.graphics_state(doc, scope, &op); + Some(op) + } + b"BT" | b"Tc" | b"Tw" | b"Tz" | b"TL" | b"Ts" | b"Tr" | b"Td" | b"TD" | b"Tm" | b"T*" => { + self.text_state(&op); + Some(op) + } + b"Tj" | b"TJ" | b"'" | b"\"" => self.show_text(scope, op, piece), + b"BI" => Self::inline_image(&self.gs, scope, &mut self.out, op, piece), + b"sh" if scope.mode == Mode::Apply => Self::shading(&self.gs, scope, op, piece), + b"Do" => self.draw_xobject(doc, scope, resources, op, piece), + _ => Some(op), + }; + piece.ops.extend(kept); + } + + /// Path construction: collects the operator and grows the path's bounding box. + fn construct_path(&mut self, op: Op) { + let o = op.op.as_slice(); + let pts: Vec<(f64, f64)> = match o { + b"re" => op.nums::<4>().map(|[x, y, w, h]| vec![(x, y), (x + w, y), (x, y + h), (x + w, y + h)]).unwrap_or_default(), + b"h" | b"W" | b"W*" => Vec::new(), + _ => op.operands.iter().filter_map(Object::as_f64).collect::>().as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect(), + }; + for (x, y) in pts { + let (u, v) = self.gs.ctm.apply(x, y); + self.path_box = Some(match self.path_box { + None => [u, v, u, v], + Some(b) => [b[0].min(u), b[1].min(v), b[2].max(u), b[3].max(v)], + }); + } + self.clip |= matches!(o, b"W" | b"W*"); + self.path.push(op); + } + + /// A painting operator ends the path; one under a region is removed (or kept as a clip). + fn paint_path(&mut self, scope: &mut Scope<'_>, op: Op, piece: &mut Piece) { + let body = std::mem::take(&mut self.path); + let bbox = self.path_box.take(); + let clips = std::mem::replace(&mut self.clip, false); + let hit = scope.mode == Mode::Apply && op.op.as_slice() != b"n" && bbox.is_some_and(|b| scope.hits(b)); + if !hit { + piece.ops.extend(body); + piece.ops.push(op); + return; + } + let b = bbox.unwrap_or_default(); + piece.changed = true; + scope.report.paths_removed += 1; + if scope.covered(b) && clips { + // Keep the clipping, drop the painting. + piece.ops.extend(body); + piece.ops.push(Op::new("n", vec![])); + } + // Partial vector redaction must not retain underlying drawing commands + // behind clipping (F-008). Refuse partial clip retention and remove the path. + } + + fn graphics_state(&mut self, doc: &Document, scope: &mut Scope<'_>, op: &Op) { + match op.op.as_slice() { + b"q" => self.stack.push((self.gs.clone(), self.tm, self.tlm)), + b"Q" => { + if let Some((g, m, lm)) = self.stack.pop() { + (self.gs, self.tm, self.tlm) = (g, m, lm); } - clip |= matches!(o, b"W" | b"W*"); - path.push(op); - continue; } - if matches!(o, b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" | b"n") { - let body = std::mem::take(&mut path); - let bbox = path_box.take(); - let clips = std::mem::replace(&mut clip, false); - let hit = scope.mode == Mode::Apply && o != b"n" && bbox.is_some_and(|b| scope.hits(b)); - if !hit { - ops.extend(body); - ops.push(op); - continue; + b"cm" => { + if let Some(m) = op.nums::<6>() { + self.gs.ctm = Matrix(m).then(&self.gs.ctm); } - let b = bbox.unwrap_or_default(); - changed = true; - scope.report.paths_removed += 1; - if scope.covered(b) && clips { - // Keep the clipping, drop the painting. - ops.extend(body); - ops.push(Op::new("n", vec![])); + } + b"Tf" => self.select_font(doc, scope, op), + _ => { + if let Some((font, vertical, size)) = op.name(0).and_then(|n| extgstate_font(doc, scope, &self.ext_gstates, n, &self.fallback)) { + (self.gs.font, self.gs.vertical, self.gs.size) = (font, vertical, size); } - // Partial vector redaction must not retain underlying drawing commands - // behind clipping (F-008). Refuse partial clip retention and remove the path. - continue; } - if !path.is_empty() { - // A path not ended by a painting operator: pass it through. - ops.append(&mut path); - path_box = None; - clip = false; + } + } + + fn select_font(&mut self, doc: &Document, scope: &mut Scope<'_>, op: &Op) { + self.gs.size = op.num(1).unwrap_or(self.gs.size); + let Some(name) = op.name(0) else { return }; + let font_dict = self.fonts_res.get(name).and_then(|f| doc.resolve(f).as_dict().cloned()); + self.gs.vertical = font_dict.as_ref().is_some_and(|d| is_vertical(doc, d)); + self.gs.font = match scope.fonts.get(name) { + Some(f) => f.clone(), + None => { + let m = font_dict.as_ref().map_or_else(|| self.fallback.clone(), |d| Rc::new(Metrics::from_dict(doc, d))); + scope.fonts.insert(name.to_vec(), m.clone()); + m } - match o { - b"q" => stack.push(gs.clone()), - b"Q" => { - if let Some(g) = stack.pop() { - gs = g; - } - } - b"cm" => { - if let Some(m) = op.nums::<6>() { - gs.ctm = Matrix(m).then(&gs.ctm); - } - } - b"BT" => { - tm = Matrix::IDENTITY; - tlm = Matrix::IDENTITY; - } - b"Tf" => { - gs.size = op.num(1).unwrap_or(gs.size); - if let Some(name) = op.name(0) { - let font_dict = fonts_res.get(name).and_then(|f| doc.resolve(f).as_dict().cloned()); - gs.vertical = font_dict.as_ref().is_some_and(|d| is_vertical(doc, d)); - gs.font = match scope.fonts.get(name) { - Some(f) => f.clone(), - None => { - let m = font_dict.as_ref().map(|d| Rc::new(Metrics::from_dict(doc, d))).unwrap_or_else(|| fallback.clone()); - scope.fonts.insert(name.to_vec(), m.clone()); - m - } - }; - } - } - b"gs" => { - if let Some((font, vertical, size)) = op.name(0).and_then(|n| extgstate_font(doc, scope, &ext_gstates, n, &fallback)) { - (gs.font, gs.vertical, gs.size) = (font, vertical, size); - } - } - b"Tc" => gs.char_spacing = op.num(0).unwrap_or(0.0), - b"Tw" => gs.word_spacing = op.num(0).unwrap_or(0.0), - b"Tz" => gs.scale = op.num(0).unwrap_or(100.0) / 100.0, - b"TL" => gs.leading = op.num(0).unwrap_or(0.0), - b"Ts" => gs.rise = op.num(0).unwrap_or(0.0), - b"Tr" => gs.render_mode = op.num(0).unwrap_or(0.0) as i64, - b"Td" | b"TD" => { - if let Some([x, y]) = op.nums::<2>() { - if o == b"TD" { - gs.leading = -y; - } - tlm = Matrix::translate(x, y).then(&tlm); - tm = tlm; - } - } - b"Tm" => { - if let Some(m) = op.nums::<6>() { - tlm = Matrix(m); - tm = tlm; - } - } - b"T*" => { - tlm = Matrix::translate(0.0, -gs.leading).then(&tlm); - tm = tlm; - } - b"Tj" | b"TJ" | b"'" | b"\"" => { - if o == b"\"" { - gs.word_spacing = op.num(0).unwrap_or(gs.word_spacing); - gs.char_spacing = op.num(1).unwrap_or(gs.char_spacing); - } - if matches!(o, b"'" | b"\"") { - tlm = Matrix::translate(0.0, -gs.leading).then(&tlm); - tm = tlm; - } - let items: Vec = match o { - // A surplus array (`[(A)] [(B)] TJ`) is malformed, but the operator still - // takes the one immediately before it, as the renderer and every other - // reader here do (#3569), so a redaction search matches what is drawn. - b"TJ" => op.operands.last().and_then(Object::as_array).cloned().unwrap_or_default(), - _ => op.operands.last().cloned().into_iter().collect(), - }; - // Vertical-writing text is laid out horizontally by `show`, so its glyphs are - // never matched against a region. Rather than ship a document whose vertical - // text was silently left under a mark, record it so apply can fail closed. - // (Full vertical-text removal is a future enhancement.) - if gs.vertical - && scope.mode == Mode::Apply - && scope.hidden_text.is_none() - && !scope.rects.is_empty() - && items.iter().any(|o| matches!(o, Object::String(s) if !s.bytes.is_empty())) - { - out.vertical = true; - } - let (new_items, removed) = show(scope, &gs, &mut tm, &items); - if removed == 0 { - ops.push(op); - continue; - } - if scope.mode == Mode::Verify { - out.residue += removed; - ops.push(op); - continue; - } - changed = true; - scope.report.glyphs += removed; - match o { - b"\"" => { - ops.push(Op::new("Tw", vec![num(gs.word_spacing)])); - ops.push(Op::new("Tc", vec![num(gs.char_spacing)])); - ops.push(Op::new("T*", vec![])); - } - b"'" => ops.push(Op::new("T*", vec![])), - _ => {} - } - ops.push(Op::new("TJ", vec![Object::Array(new_items)])); - continue; - } - b"BI" => { - let b = gs.ctm.bbox([0.0, 0.0, 1.0, 1.0]); - if scope.hits(b) { - if scope.mode == Mode::Verify { - out.residue += 1; - } else { - scope.report.images_removed += 1; - changed = true; - continue; - } - } - } - b"sh" if scope.mode == Mode::Apply => { - // A shading fills the current clip: clip the regions out. - if let Some(c) = clip_out(scope.rects, &gs.ctm, [-1e6, -1e6, 1e6, 1e6]) { - changed = true; - ops.push(Op::new("q", vec![])); - ops.extend(c); - ops.push(op); - ops.push(Op::new("Q", vec![])); - continue; + }; + } + + /// Text state and text-positioning operators. + fn text_state(&mut self, op: &Op) { + let gs = &mut self.gs; + match op.op.as_slice() { + b"BT" => { + self.tm = Matrix::IDENTITY; + self.tlm = Matrix::IDENTITY; + } + b"Tc" => gs.char_spacing = op.num(0).unwrap_or(0.0), + b"Tw" => gs.word_spacing = op.num(0).unwrap_or(0.0), + b"Tz" => gs.scale = op.num(0).unwrap_or(100.0) / 100.0, + b"TL" => gs.leading = op.num(0).unwrap_or(0.0), + b"Ts" => gs.rise = op.num(0).unwrap_or(0.0), + b"Tr" => gs.render_mode = op.num(0).unwrap_or(0.0) as i64, + b"Td" | b"TD" => { + if let Some([x, y]) = op.nums::<2>() { + if op.is("TD") { + gs.leading = -y; } + self.tlm = Matrix::translate(x, y).then(&self.tlm); + self.tm = self.tlm; } - b"Do" => { - if let Some(new) = xobject(doc, scope, &op, &gs, resources, &mut xobjects, &mut out) { - changed = true; - if let Some(n) = new { - ops.push(n); - } - continue; - } + } + b"Tm" => { + if let Some(m) = op.nums::<6>() { + self.tlm = Matrix(m); + self.tm = self.tlm; } - _ => {} } - ops.push(op); + _ => { + self.tlm = Matrix::translate(0.0, -gs.leading).then(&self.tlm); + self.tm = self.tlm; + } + } + } + + /// `Tj`, `TJ`, `'` and `"`: glyphs under a region are cut out (or counted, when verifying). + fn show_text(&mut self, scope: &mut Scope<'_>, op: Op, piece: &mut Piece) -> Option { + let o = op.op.as_slice(); + if o == b"\"" { + self.gs.word_spacing = op.num(0).unwrap_or(self.gs.word_spacing); + self.gs.char_spacing = op.num(1).unwrap_or(self.gs.char_spacing); + } + if matches!(o, b"'" | b"\"") { + self.tlm = Matrix::translate(0.0, -self.gs.leading).then(&self.tlm); + self.tm = self.tlm; + } + let items: Vec = match o { + // A surplus array (`[(A)] [(B)] TJ`) is malformed, but the operator still + // takes the one immediately before it, as the renderer and every other + // reader here do (#3569), so a redaction search matches what is drawn. + b"TJ" => op.operands.last().and_then(Object::as_array).cloned().unwrap_or_default(), + _ => op.operands.last().cloned().into_iter().collect(), + }; + // Vertical-writing text is laid out horizontally by `show`, so its glyphs are + // never matched against a region. Rather than ship a document whose vertical + // text was silently left under a mark, record it so apply can fail closed. + // (Full vertical-text removal is a future enhancement.) + if self.gs.vertical + && scope.mode == Mode::Apply + && scope.hidden_text.is_none() + && !scope.rects.is_empty() + && items.iter().any(|o| matches!(o, Object::String(s) if !s.bytes.is_empty())) + { + self.out.vertical = true; + } + let (new_items, removed) = show(scope, &self.gs, &mut self.tm, &items); + if removed == 0 { + return Some(op); } - if !path.is_empty() { - ops.append(&mut path); + if scope.mode == Mode::Verify { + self.out.residue += removed; + return Some(op); } - processed.push((ops, changed)); + piece.changed = true; + scope.report.glyphs += removed; + match op.op.as_slice() { + b"\"" => { + piece.ops.push(Op::new("Tw", vec![num(self.gs.word_spacing)])); + piece.ops.push(Op::new("Tc", vec![num(self.gs.char_spacing)])); + piece.ops.push(Op::new("T*", vec![])); + } + b"'" => piece.ops.push(Op::new("T*", vec![])), + _ => {} + } + piece.ops.push(Op::new("TJ", vec![Object::Array(new_items)])); + None } - if scope.mode == Mode::Apply { - clean_replacements(doc, resources, &original, &mut processed, scope.report); + + /// An inline image under a region is removed (or counted, when verifying). + fn inline_image(gs: &Gs, scope: &mut Scope<'_>, out: &mut Output, op: Op, piece: &mut Piece) -> Option { + if !scope.hits(gs.ctm.bbox([0.0, 0.0, 1.0, 1.0])) { + return Some(op); + } + if scope.mode == Mode::Verify { + out.residue += 1; + return Some(op); + } + scope.report.images_removed += 1; + piece.changed = true; + None } - for ((ops, changed), (comment_in, comment_out)) in processed.into_iter().zip(comment_edges) { - out.streams.push(changed.then(|| { - // The piece before still ends inside a comment: end it before the first operator. - let mut bytes = if comment_in { b"\n".to_vec() } else { Vec::new() }; - bytes.extend(serialize_ops(&ops)); - // The piece ended inside a comment that hid the start of the next one: keep it open. - if comment_out { - bytes.push(b'%'); - } - bytes - })); + + /// A shading fills the current clip: the regions are clipped out. + fn shading(gs: &Gs, scope: &Scope<'_>, op: Op, piece: &mut Piece) -> Option { + let Some(c) = clip_out(scope.rects, &gs.ctm, [-1e6, -1e6, 1e6, 1e6]) else { return Some(op) }; + piece.changed = true; + piece.ops.push(Op::new("q", vec![])); + piece.ops.extend(c); + piece.ops.push(op); + piece.ops.push(Op::new("Q", vec![])); + None + } + + fn draw_xobject(&mut self, doc: &mut Document, scope: &mut Scope<'_>, resources: &Dict, op: Op, piece: &mut Piece) -> Option { + let Some(new) = xobject(doc, scope, &op, &self.gs, resources, &mut self.xobjects, &mut self.out) else { return Some(op) }; + piece.changed = true; + piece.ops.extend(new); + None } - out } fn clean_replacements(doc: &Document, resources: &Dict, original: &[Op], processed: &mut [(Vec, bool)], report: &mut Report) { @@ -1226,7 +753,7 @@ fn xobject( out.doomed.push(r); return Some(None); } - match image::clear(doc, s, &gs.ctm, scope.rects) { + match image::clear(doc, s, &gs.ctm, scope.rects, &mut *scope.images) { Ok(None) => None, Ok(Some(new)) => { let nr = doc.add(Object::Stream(new)); @@ -1334,6 +861,11 @@ fn xobject( } let mut dict = s.dict.clone(); dict.remove(b"Length"); + if new_data.is_some() { + // As for page content streams: the source text of an added item (Edit ▸ Add + // content) would keep what the rewrite removed. + dict.remove(b"PCAdded"); + } let mut cleaned_res = res.clone(); // Install rewritten nested forms before finding live property names: a new form may // still inherit its caller's resources, and its new Do name must resolve here. @@ -1386,45 +918,3 @@ fn xobject( _ => None, } } - -/// Remove marked-content blocks of hidden layers (`/OC /name BDC … EMC`, nested blocks -/// included). Returns the remaining operators and how many blocks went. -fn strip_hidden_layers(doc: &Document, scope: &mut Scope<'_>, ops: Vec, props: &Dict) -> (Vec, usize) { - let mut out = Vec::with_capacity(ops.len()); - // For each open marked-content block: does it hide its contents? - let mut stack: Vec = Vec::new(); - let mut removed = 0; - for op in ops { - let hidden_now = stack.last().copied().unwrap_or(false); - match op.op.as_slice() { - b"BDC" | b"BMC" => { - let this = op.is("BDC") - && op.name(0) == Some(b"OC") - && match op.operands.get(1) { - Some(Object::Name(n)) => props.get(n).is_some_and(|o| scope.layer_hidden(doc, o)), - Some(o) => scope.layer_hidden(doc, o), - None => false, - }; - if this && !hidden_now { - removed += 1; - } - stack.push(hidden_now || this); - if !(hidden_now || this) { - out.push(op); - } - } - b"EMC" => { - let was = stack.pop().unwrap_or(false); - if !was { - out.push(op); - } - } - _ => { - if !hidden_now { - out.push(op); - } - } - } - } - (out, removed) -} diff --git a/crates/redact/src/interp/layers.rs b/crates/redact/src/interp/layers.rs new file mode 100644 index 0000000000..88d1c4e677 --- /dev/null +++ b/crates/redact/src/interp/layers.rs @@ -0,0 +1,256 @@ +use super::{Dict, Document, HashSet, ObjRef, Object, Op, Scope}; + +impl<'a> Scope<'a> { + /// Is this optional-content reference (an OCG or OCMD) hidden? + fn layer_hidden(&mut self, doc: &Document, o: &Object) -> bool { + if !self.layers_enabled { + return false; + } + let reference = o.as_ref(); + if let Some(r) = reference { + if let Some(hidden) = self.ocmd_cache.get(&r) { + return *hidden; + } + if self.hidden_layers.contains(&r) { + self.ocmd_cache.insert(r, true); + return true; + } + } + // Evaluate the policy against the same default configuration used to select hidden + // groups. A membership dictionary's default policy is AnyOn. + let d = doc.resolve(o); + let Some(d) = d.as_dict() else { return false }; + if d.name(b"Type") != Some(b"OCMD") { + return false; + } + let groups: Vec = match d.get(b"OCGs").map(|g| (*doc.resolve(g)).clone()) { + Some(Object::Array(a)) => a.iter().filter_map(Object::as_ref).collect(), + Some(_) => d.get(b"OCGs").and_then(Object::as_ref).into_iter().collect(), + None => Vec::new(), + }; + let fail_closed = || groups.iter().any(|g| self.hidden_layers.contains(g)); + let hidden = if let Some(expression) = d.get(b"VE") { + let visible = self.visibility_expression(doc, expression, 0, &mut HashSet::new(), &mut 4096); + match visible { + Some(visible) => !visible, + None => fail_closed() || self.expression_mentions_hidden(doc, expression, 0, &mut HashSet::new(), &mut 4096), + } + } else if groups.is_empty() { + false + } else { + let any_on = groups.iter().any(|g| !self.hidden_layers.contains(g)); + let all_on = groups.iter().all(|g| !self.hidden_layers.contains(g)); + let policy = d.get(b"P").map(|p| doc.resolve(p)).and_then(|p| p.as_name().map(|name| name.to_vec())); + match (d.get(b"P"), policy.as_deref()) { + (None, _) | (Some(_), Some(b"AnyOn")) => !any_on, + (Some(_), Some(b"AllOn")) => !all_on, + (Some(_), Some(b"AnyOff")) => all_on, + (Some(_), Some(b"AllOff")) => any_on, + // An invalid policy must not make a candidate hidden block survive sanitization. + _ => fail_closed(), + } + }; + if !hidden { + self.retained_off_layers.extend(groups.iter().filter(|g| self.hidden_layers.contains(g)).copied()); + if let Some(expression) = d.get(b"VE") { + let mut referenced = HashSet::new(); + if Self::collect_hidden_expression_groups(doc, expression, &self.hidden_layers, 0, &mut HashSet::new(), &mut 4096, &mut referenced) { + self.retained_off_layers.extend(referenced); + } else { + // If an expression cannot be fully walked, preserve every off group so that + // the content we kept does not change visibility after configuration cleanup. + self.retained_off_layers.extend(self.hidden_layers.iter().copied()); + } + } + } + if let Some(r) = reference { + self.ocmd_cache.insert(r, hidden); + } + hidden + } + + pub(super) fn xobject_layer_hidden(&mut self, doc: &Document, xobject: ObjRef, oc: &Object) -> bool { + if oc.as_ref().is_some() { + return self.layer_hidden(doc, oc); + } + if let Some(hidden) = self.xobject_oc_cache.get(&xobject) { + return *hidden; + } + let hidden = self.layer_hidden(doc, oc); + self.xobject_oc_cache.insert(xobject, hidden); + hidden + } + + fn collect_hidden_expression_groups( + doc: &Document, + expression: &Object, + hidden_layers: &HashSet, + depth: usize, + path: &mut HashSet, + budget: &mut usize, + found: &mut HashSet, + ) -> bool { + if depth > 64 || *budget == 0 { + return false; + } + *budget -= 1; + let reference = expression.as_ref(); + if let Some(r) = reference + && !path.insert(r) + { + return false; + } + let resolved = doc.resolve(expression); + let complete = match &*resolved { + Object::Dict(d) if d.name(b"Type") == Some(b"OCG") => { + if let Some(r) = reference + && hidden_layers.contains(&r) + { + found.insert(r); + } + reference.is_some() + } + Object::Array(items) => { + let Some(Object::Name(operator)) = items.first() else { return false }; + let operands = match operator.as_slice() { + b"And" | b"Or" if (2..=4097).contains(&items.len()) => items.get(1..), + b"Not" if items.len() == 2 => items.get(1..), + _ => None, + }; + operands.is_some_and(|operands| { + operands.iter().all(|item| Self::collect_hidden_expression_groups(doc, item, hidden_layers, depth + 1, path, budget, found)) + }) + } + _ => false, + }; + if let Some(r) = reference { + path.remove(&r); + } + complete + } + + /// Evaluate an optional-content visibility expression (`/VE`) against the default config. + /// Malformed, cyclic, or excessive expressions return `None` so the caller can fail closed. + fn visibility_expression( + &self, + doc: &Document, + expression: &Object, + depth: usize, + path: &mut HashSet, + budget: &mut usize, + ) -> Option { + if depth > 64 || *budget == 0 { + return None; + } + *budget -= 1; + let reference = expression.as_ref(); + if let Some(r) = reference + && !path.insert(r) + { + return None; + } + let resolved = doc.resolve(expression); + let result = match &*resolved { + Object::Dict(d) if d.name(b"Type") == Some(b"OCG") => reference.map(|r| !self.hidden_layers.contains(&r)), + Object::Array(items) if (2..=4097).contains(&items.len()) => { + let operator = match items.first() { + Some(Object::Name(n)) => n.as_slice(), + _ => return None, + }; + match operator { + b"And" if items.len() >= 2 => { + let mut visible = true; + for item in items.iter().skip(1) { + visible &= self.visibility_expression(doc, item, depth + 1, path, budget)?; + } + Some(visible) + } + b"Or" if items.len() >= 2 => { + let mut visible = false; + for item in items.iter().skip(1) { + visible |= self.visibility_expression(doc, item, depth + 1, path, budget)?; + } + Some(visible) + } + b"Not" if items.len() == 2 => Some(!self.visibility_expression(doc, &items[1], depth + 1, path, budget)?), + _ => None, + } + } + _ => None, + }; + if let Some(r) = reference { + path.remove(&r); + } + result + } + + /// Conservatively find hidden groups in an expression that could not be evaluated. Unknown + /// syntax, cycles, and exhausted budgets count as a possible hidden reference. + fn expression_mentions_hidden(&self, doc: &Document, expression: &Object, depth: usize, path: &mut HashSet, budget: &mut usize) -> bool { + if depth > 64 || *budget == 0 { + return true; + } + *budget -= 1; + if let Some(r) = expression.as_ref() { + if self.hidden_layers.contains(&r) { + return true; + } + if !path.insert(r) { + return true; + } + } + let resolved = doc.resolve(expression); + let found = match &*resolved { + Object::Array(items) => items.iter().any(|item| self.expression_mentions_hidden(doc, item, depth + 1, path, budget)), + Object::Dict(d) if d.name(b"Type") == Some(b"OCG") => expression.as_ref().is_none_or(|r| self.hidden_layers.contains(&r)), + Object::Dict(_) => true, + _ => false, + }; + if let Some(r) = expression.as_ref() { + path.remove(&r); + } + found + } +} + +/// Remove marked-content blocks of hidden layers (`/OC /name BDC … EMC`, nested blocks +/// included). Returns the remaining operators and how many blocks went. +pub(super) fn strip_hidden_layers(doc: &Document, scope: &mut Scope<'_>, ops: Vec, props: &Dict) -> (Vec, usize) { + let mut out = Vec::with_capacity(ops.len()); + // For each open marked-content block: does it hide its contents? + let mut stack: Vec = Vec::new(); + let mut removed = 0; + for op in ops { + let hidden_now = stack.last().copied().unwrap_or(false); + match op.op.as_slice() { + b"BDC" | b"BMC" => { + let this = op.is("BDC") + && op.name(0) == Some(b"OC") + && match op.operands.get(1) { + Some(Object::Name(n)) => props.get(n).is_some_and(|o| scope.layer_hidden(doc, o)), + Some(o) => scope.layer_hidden(doc, o), + None => false, + }; + if this && !hidden_now { + removed += 1; + } + stack.push(hidden_now || this); + if !(hidden_now || this) { + out.push(op); + } + } + b"EMC" => { + let was = stack.pop().unwrap_or(false); + if !was { + out.push(op); + } + } + _ => { + if !hidden_now { + out.push(op); + } + } + } + } + (out, removed) +} diff --git a/crates/redact/src/interp/prune.rs b/crates/redact/src/interp/prune.rs new file mode 100644 index 0000000000..6ca5ea2803 --- /dev/null +++ b/crates/redact/src/interp/prune.rs @@ -0,0 +1,357 @@ +use super::{ + CosError, Dict, Document, HashMap, HashSet, MAX_DEPTH, Matrix, Metrics, ObjRef, Object, Op, Pieces, Rc, RedactError, Scope, is_vertical, num, + overlaps, page_streams, res_dict, +}; + +/// Drop every `replaced` resource name whose `Do` no longer appears in the rewritten `content`, +/// so a full-rewrite save stops reaching the original image or form it named. A name that some +/// surviving `Do` still draws (the same image shown again outside a mark) is kept. +pub(crate) fn prune(xo: &mut Dict, replaced: &[Vec], content: &[Vec]) { + if replaced.is_empty() { + return; + } + let joined = Pieces::join(content); + let ops = joined.parse().ops; + let still_drawn = |n: &[u8]| ops.iter().any(|op| op.is("Do") && op.last_name() == Some(n)); + for n in replaced { + if !still_drawn(n) { + xo.remove(n); + } + } +} + +/// Whether `content` draws the XObject `name` of the resource dictionary `xo`: directly, or +/// through a form that has no /Resources of its own (such a form draws through its caller's). +/// `None` when that cannot be told (a form that will not decode, or nesting past the limit). +fn content_draws(doc: &Document, content: &[Vec], xo: &Dict, name: &[u8], depth: usize, seen: &mut HashSet) -> Option { + let joined = Pieces::join(content); + let ops = joined.parse().ops; + for op in ops.iter().filter(|op| op.is("Do")) { + let Some(n) = op.last_name() else { continue }; + if n == name { + return Some(true); + } + let Some(r) = xo.get(n).and_then(Object::as_ref) else { continue }; + let obj = doc.get(r); + let Object::Stream(s) = &*obj else { continue }; + if s.dict.name(b"Subtype") != Some(b"Form") || s.dict.contains(b"Resources") || !seen.insert(r) { + continue; + } + if depth >= MAX_DEPTH { + return None; + } + let Ok(data) = s.decoded() else { return None }; + match content_draws(doc, std::slice::from_ref(&data), xo, name, depth + 1, seen) { + Some(false) => {} + other => return other, + } + } + Some(false) +} + +/// Unlink the XObjects a rewrite dropped or replaced (`doomed`) from every page-tree resource +/// dictionary that still names them. The rewritten page has its own pruned /Resources, but the +/// originals stay reachable, and so stay in a full-rewrite save, when: +/// - the page inherited its /Resources from a /Pages node, which still lists them; +/// - several pages share one resource dictionary, and another page still refers to it; +/// - a form without /Resources drew them through the page's dictionary. +/// +/// An entry goes unless a page that draws through that dictionary still draws the name (the same +/// image shown on another page, or again outside a mark). A dictionary that loses an entry is +/// replaced by a private copy on its node, so a dictionary object shared with other nodes is +/// left as it is for them. Retired properties are likewise unlinked only when none of the +/// holder's pages or inheriting forms names them. Fails closed when use cannot be determined. +pub(crate) fn unlink_doomed(doc: &mut Document, doomed: &[ObjRef], retired_properties: &[Object]) -> Result<(), RedactError> { + crate::type3::unlink_type3(doc)?; + if doomed.is_empty() && retired_properties.is_empty() { + return Ok(()); + } + let doomed: HashSet = doomed.iter().copied().collect(); + let Some(top) = doc.root().and_then(|root| doc.get(root).as_dict().and_then(|d| d.reference(b"Pages"))) else { return Ok(()) }; + // Every page-tree node with a /Resources of its own, and the pages that draw through it + // (the nearest such node above a page, or the page itself). Cycles are cut as in + // `pdfcraft_model::pages`. + let mut holders: Vec<(ObjRef, Vec)> = Vec::new(); + let mut seen = HashSet::new(); + let mut stack: Vec<(ObjRef, Option)> = vec![(top, None)]; + while let Some((node, mut holder)) = stack.pop() { + if !seen.insert(node) || seen.len() > 1_000_000 { + continue; + } + let obj = doc.get(node); + let Some(d) = obj.as_dict() else { continue }; + if d.contains(b"Resources") { + holders.push((node, Vec::new())); + holder = Some(holders.len() - 1); + } + let is_pages = d.name(b"Type") == Some(b"Pages") || (d.contains(b"Kids") && d.name(b"Type") != Some(b"Page")); + if is_pages { + let kids = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default(); + stack.extend(kids.iter().filter_map(Object::as_ref).map(|k| (k, holder))); + } else if let Some(h) = holder.and_then(|i| holders.get_mut(i)) { + h.1.push(node); + } + } + let index: HashMap = pdfcraft_model::pages(doc).iter().enumerate().map(|(i, p)| (p.obj, i)).collect(); + let holder_nodes: Vec = holders.iter().map(|(node, _)| *node).collect(); + for (node, users) in holders { + let Some(mut res) = doc.get(node).as_dict().and_then(|d| d.get(b"Resources")).and_then(|r| doc.resolve(r).as_dict().cloned()) else { + continue; + }; + let mut xo = res_dict(doc, &res, b"XObject"); + let names: Vec> = xo.iter().filter(|(_, v)| v.as_ref().is_some_and(|r| doomed.contains(&r))).map(|(n, _)| n.to_vec()).collect(); + let properties = res_dict(doc, &res, b"Properties"); + let property_names: Vec> = properties.iter().filter(|(_, v)| retired_properties.contains(v)).map(|(n, _)| n.to_vec()).collect(); + if names.is_empty() && property_names.is_empty() { + continue; + } + let mut contents = Vec::with_capacity(users.len()); + for user in &users { + let page = doc.get(*user).as_dict().cloned().unwrap_or_default(); + // A page whose content cannot be read may still draw the name: fail closed. + contents.push(page_streams(doc, &page, index.get(user).copied().unwrap_or(0))?.1); + } + let mut pruned = false; + for n in names { + let mut drawn = Some(false); + for content in &contents { + drawn = content_draws(doc, content, &xo, &n, 0, &mut HashSet::new()); + if drawn != Some(false) { + break; + } + } + match drawn { + Some(true) => {} + Some(false) => { + xo.remove(&n); + pruned = true; + } + None => return Err(RedactError::Residue(1)), + } + } + if pruned { + res.set(b"XObject".to_vec(), Object::Dict(xo)); + } + pruned |= prune_retired_properties(doc, &mut res, &property_names, &contents)?; + if pruned { + doc.update_dict(node, |d| d.set(b"Resources".to_vec(), Object::Dict(res)))?; + } + } + // A surviving form can own the same old resource dictionary as a rewritten page. Its + // unused descriptions and doomed XObjects remain reachable too, even after every page + // holder is cleaned. + unlink_from_forms(doc, &holder_nodes, &doomed, retired_properties)?; + Ok(()) +} + +/// Retire only properties removed from a rewritten scope, preserving names still used by any +/// content sharing this holder. Different pages' streams must never be concatenated together. +fn prune_retired_properties(doc: &Document, res: &mut Dict, names: &[Vec], contents: &[Vec>]) -> Result { + if names.is_empty() { + return Ok(false); + } + let mut used = HashSet::new(); + for content in contents { + for op in resource_ops(doc, res, content)? { + if matches!(op.op.as_slice(), b"BDC" | b"DP") + && let Some(name) = op.name(1) + { + used.insert(name.to_vec()); + } + } + } + let mut properties = res_dict(doc, res, b"Properties"); + let mut pruned = false; + for name in names { + if !used.contains(name) { + pruned |= properties.remove(name).is_some(); + } + } + if pruned { + if properties.is_empty() { + res.remove(b"Properties"); + } else { + res.set(b"Properties".to_vec(), Object::Dict(properties)); + } + } + Ok(pruned) +} + +/// Unlink retired properties and `doomed` XObjects from the resources of the forms reachable from +/// the page-tree `holders`, each unless the form's own content (or a form inheriting its +/// resources) still uses the name. Fails closed when that cannot be determined. +fn unlink_from_forms(doc: &mut Document, holders: &[ObjRef], doomed: &HashSet, retired: &[Object]) -> Result<(), CosError> { + if retired.is_empty() && doomed.is_empty() { + return Ok(()); + } + let mut pending: Vec = holders + .iter() + .filter_map(|r| doc.get(*r).as_dict().and_then(|d| d.get(b"Resources")).and_then(|r| doc.resolve(r).as_dict().cloned())) + .collect(); + let mut seen = HashSet::new(); + while let Some(resources) = pending.pop() { + for (_, value) in res_dict(doc, &resources, b"XObject").iter() { + let Some(reference) = value.as_ref() else { continue }; + if !seen.insert(reference) { + continue; + } + if seen.len() > 1_000_000 { + return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect shared form resources".into() }); + } + let object = doc.get(reference); + let Object::Stream(form) = &*object else { continue }; + if form.dict.name(b"Subtype") != Some(b"Form") { + continue; + } + let Some(mut res) = form.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()) else { continue }; + let names: Vec> = + res_dict(doc, &res, b"Properties").iter().filter(|(_, value)| retired.contains(value)).map(|(name, _)| name.to_vec()).collect(); + let mut xo = res_dict(doc, &res, b"XObject"); + let doomed_names: Vec> = + xo.iter().filter(|(_, v)| v.as_ref().is_some_and(|r| doomed.contains(&r))).map(|(n, _)| n.to_vec()).collect(); + if names.is_empty() && doomed_names.is_empty() { + pending.push(res); + continue; + } + let data = form.decoded()?; + let mut pruned = prune_doomed_xobjects(doc, std::slice::from_ref(&data), &mut xo, &doomed_names)?; + if pruned { + res.set(b"XObject".to_vec(), Object::Dict(xo)); + } + pruned |= prune_retired_properties(doc, &mut res, &names, &[vec![data]])?; + if pruned { + doc.update_dict(reference, |d| d.set(b"Resources".to_vec(), Object::Dict(res.clone())))?; + } + pending.push(res); + } + } + Ok(()) +} + +fn prune_doomed_xobjects(doc: &Document, content: &[Vec], xo: &mut Dict, doomed_names: &[Vec]) -> Result { + let mut pruned = false; + for name in doomed_names { + match content_draws(doc, content, xo, name, 0, &mut HashSet::new()) { + Some(true) => {} + Some(false) => pruned |= xo.remove(name).is_some(), + None => return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect shared form resources".into() }), + } + } + Ok(pruned) +} + +/// The clip that hides everything inside each region: one even-odd clip per region (a big +/// rectangle with the region cut out), in the current user space. Returns `None` when the CTM +/// can't be inverted (nothing is painted then anyway). +pub(super) fn clip_out(rects: &[[f64; 4]], ctm: &Matrix, around: [f64; 4]) -> Option> { + let inv = ctm.invert()?; + let mut ops = Vec::new(); + let big = [around[0] - 10.0, around[1] - 10.0, around[2] + 10.0, around[3] + 10.0]; + for r in rects.iter().filter(|r| overlaps(**r, around, 0.0)) { + let outer = [(big[0], big[1]), (big[2], big[1]), (big[2], big[3]), (big[0], big[3])]; + let inner = [(r[0], r[1]), (r[0], r[3]), (r[2], r[3]), (r[2], r[1])]; + for poly in [outer, inner] { + for (k, (x, y)) in poly.iter().enumerate() { + let (u, v) = inv.apply(*x, *y); + ops.push(Op::new(if k == 0 { "m" } else { "l" }, vec![num(u), num(v)])); + } + ops.push(Op::new("h", vec![])); + } + ops.push(Op::new("W*", vec![])); + ops.push(Op::new("n", vec![])); + } + Some(ops) +} + +pub(crate) fn resource_ops(doc: &Document, res: &Dict, content: &[Vec]) -> Result, CosError> { + let parsed = Pieces::join(content).parse(); + if parsed.skipped > 0 || parsed.ops.len() > 2_000_000 { + return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect property resources".into() }); + } + let mut ops = parsed.ops; + let xobjects = res_dict(doc, res, b"XObject"); + let mut seen = HashSet::new(); + let mut cursor = 0; + while cursor < ops.len() { + let op = &ops[cursor]; + if op.is("Do") + && let Some(r) = op.last_name().and_then(|n| xobjects.get(n)).and_then(Object::as_ref) + && seen.insert(r) + && let Object::Stream(s) = &*doc.get(r) + && s.dict.name(b"Subtype") == Some(b"Form") + && s.dict.get(b"Resources").is_none_or(|r| doc.resolve(r).as_dict().is_none()) + { + let parsed = pdfcraft_content::parse(&s.decoded()?); + if seen.len() > 2_000_000 || ops.len().saturating_add(parsed.ops.len()) > 2_000_000 || parsed.skipped > 0 { + return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect inherited form resources".into() }); + } + ops.extend(parsed.ops); + } + cursor += 1; + } + Ok(ops) +} + +pub(crate) fn prune_properties(doc: &Document, res: &mut Dict, content: &[Vec]) -> Result, CosError> { + let mut properties = res_dict(doc, res, b"Properties"); + if properties.is_empty() { + return Ok(Vec::new()); + } + let mut retired = Vec::new(); + let ops = resource_ops(doc, res, content)?; + let names: Vec> = properties.iter().map(|(n, _)| n.to_vec()).collect(); + for name in names { + if !ops.iter().any(|op| matches!(op.op.as_slice(), b"BDC" | b"DP") && op.name(1) == Some(&name)) + && let Some(value) = properties.remove(&name) + { + retired.push(value); + } + } + if properties.is_empty() { + res.remove(b"Properties"); + } else { + res.set(b"Properties".to_vec(), Object::Dict(properties)); + } + Ok(retired) +} + +pub(super) fn prune_form_resources(doc: &Document, res: &mut Dict, content: &[Vec]) -> Result<(), CosError> { + let ops = resource_ops(doc, res, content)?; + let mut xo = res_dict(doc, res, b"XObject"); + let names: Vec> = xo.iter().map(|(n, _)| n.to_vec()).collect(); + for name in names { + if !ops.iter().any(|op| op.is("Do") && op.last_name() == Some(&name)) { + xo.remove(&name); + } + } + res.set(b"XObject".to_vec(), Object::Dict(xo)); + Ok(()) +} + +/// The font, writing direction and size an ExtGState's `/Font [font size]` entry sets, as `Tf` +/// would (ISO 32000-2 §8.4.5); `None` when `name` sets no font. The metrics are cached with the +/// fonts `Tf` names, under a key no resource name can take (a name cannot hold a null byte). +pub(super) fn extgstate_font( + doc: &Document, + scope: &mut Scope<'_>, + ext_gstates: &Dict, + name: &[u8], + fallback: &Rc, +) -> Option<(Rc, bool, f64)> { + let state = doc.resolve(ext_gstates.get(name)?).as_dict().cloned()?; + let entry = doc.resolve(state.get(b"Font")?).as_array().cloned()?; + let [font, size] = entry.as_slice() else { return None }; + let size = doc.resolve(size).as_f64()?; + let font_dict = doc.resolve(font).as_dict().cloned(); + let vertical = font_dict.as_ref().is_some_and(|d| is_vertical(doc, d)); + let key = [b"\0".as_slice(), name].concat(); + let metrics = match scope.fonts.get(&key) { + Some(m) => m.clone(), + None => { + let m = font_dict.as_ref().map(|d| Rc::new(Metrics::from_dict(doc, d))).unwrap_or_else(|| fallback.clone()); + scope.fonts.insert(key, m.clone()); + m + } + }; + Some((metrics, vertical, size)) +} diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index 6f69151d89..41ac47357e 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -70,6 +70,16 @@ pub enum RedactError { /// could not be shown complete. #[error("the document's objects nest too deeply to remove hidden information completely; the operation was refused")] TooDeep, + /// Past what one redaction may decode an image can't be cleared, and removing it whole would + /// take what no mark covers with it, unreported. + #[error("the images under these marks are more than one redaction can decode, so nothing was changed; redact fewer pages at a time")] + ImagesPastAllowance, + /// A JBIG2 image shares its `/JBIG2Globals` with a redacted one and can't be stored again + /// without them, so the shapes they hold, those of marks under the area included, would stay. + #[error( + "{0} JBIG2 image(s) share their symbol data with a redacted image and can't be decoded to be stored without it, so that data, which can show what is under the marks, would stay in the file; nothing was redacted" + )] + SharedJbig2Globals(usize), #[error(transparent)] Edit(#[from] pdfcraft_edit::EditError), #[error(transparent)] @@ -132,6 +142,9 @@ pub struct Report { pub glyphs: usize, pub images_removed: usize, pub images_cleared: usize, + /// JBIG2 images, anywhere in the document, stored again (as a cleared copy is) without the + /// `/JBIG2Globals` they shared with a redacted image, so those globals leave the file. + pub images_restored: usize, pub paths_removed: usize, pub paths_clipped: usize, pub forms_rewritten: usize, @@ -377,6 +390,12 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result) -> Result { + let images = pdfcraft_cos::ImageBudget::new(doc); + apply_within(doc, pages, images) +} + +/// [`apply_to`], its images decoded within `images`. +fn apply_within(doc: &mut Document, pages: Option<&[usize]>, images: pdfcraft_cos::ImageBudget) -> Result { let (all_marks, unreadable) = marks_checked(doc); // A mark with no readable area is not applied, and applying the others would look complete: // refuse before anything changes. Only the pages being applied count. @@ -409,193 +428,28 @@ fn apply_to(doc: &mut Document, pages: Option<&[usize]>) -> Result, _>>()?; report.pages = by_page.len(); localize_resources(doc, &by_page)?; - let mut doomed_fields: Vec = Vec::new(); - // The original image and form XObjects the rewrite drops or replaces, on any page. - let mut doomed_xobjects: Vec = Vec::new(); - let mut retired_properties = Vec::new(); let widget_owner: Vec<(ObjRef, ObjRef)> = pdfcraft_forms::fields(doc).into_iter().flat_map(|f| f.widgets.iter().map(|w| (w.obj, f.obj)).collect::>()).collect(); let listed_before = listed_annotations(doc); - let mut removed_everywhere: HashSet = HashSet::new(); - + let mut applying = Applying { + report, + doomed_fields: Vec::new(), + doomed_xobjects: Vec::new(), + retired_properties: Vec::new(), + widget_owner, + removed_everywhere: HashSet::new(), + images, + }; for &(pi, page_marks) in &groups { let page = pdfcraft_model::pages(doc).swap_remove(pi); let rects: Vec<[f64; 4]> = page_marks.iter().flat_map(|m| m.rects.iter().copied()).collect(); - - // 1. Content. - let mut budget = pdfcraft_cos::DecodeBudget::page(); - let (list, data) = page_streams_within(doc, &page.dict, pi, &mut budget)?; - let resources = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); - let out = { - let mut scope = Scope::new(&rects, Mode::Apply, &mut report, budget); - process(doc, &mut scope, &data, &resources, pdfcraft_content::Matrix::IDENTITY) - }; - // Fail closed: the interpreter lays vertical-writing text out horizontally, so it cannot - // tell whether those glyphs lie under a mark. Rather than report success and possibly - // leave the text in place, refuse the whole operation (the caller keeps the previous - // document). The error does not claim the text was under the mark. Full vertical-text - // removal is a future enhancement. - if let Some(error) = out.error { - return Err(error.into()); - } - if out.vertical { - return Err(RedactError::VerticalText(pi + 1)); - } - // An inline image the renderer reads no image for (no `EI` it accepts, or parameters it - // can't read): where the page's operators resume after it is a guess, so neither removal - // nor the verify pass can be trusted on this page. - if out.ambiguous { - return Err(RedactError::Unreadable(pi + 1)); - } - doomed_xobjects.extend_from_slice(&out.doomed); - retired_properties.extend(out.retired_properties); - let mut new_list = list.clone(); - let mut changed = false; - for (i, new) in out.streams.into_iter().enumerate() { - let Some(bytes) = new else { continue }; - let mut dict = match &*doc.resolve(&list[i]) { - Object::Stream(s) => s.dict.clone(), - _ => Dict::new(), - }; - dict.remove(b"Length"); - // Edit ▸ Add content keeps an added item's source text in `/PCAdded`; once its glyphs - // are redacted, keeping it would leave the redacted text in the file. The item stays - // as plain drawn content but is no longer editable. - dict.remove(b"PCAdded"); - // A new object: the original may be shared with other pages. - new_list[i] = Object::Ref(doc.add(Object::Stream(Stream::flate(dict, &bytes)))); - changed = true; - } - if changed || !out.xobjects.is_empty() || !out.replaced.is_empty() { - let mut res = resources.clone(); - if !out.xobjects.is_empty() || !out.replaced.is_empty() { - let mut xo = res.get(b"XObject").and_then(|x| doc.resolve(x).as_dict().cloned()).unwrap_or_default(); - // A full save keeps whatever is still referenced: unlink the originals this page no - // longer draws so the removed image or form bytes cannot survive in the saved file. - let page_content: Vec> = new_list - .iter() - .map(|o| match &*doc.resolve(o) { - Object::Stream(s) => s.decoded().unwrap_or_default(), - _ => Vec::new(), - }) - .collect(); - interp::prune(&mut xo, &out.replaced, &page_content); - for (n, r) in &out.xobjects { - xo.set(n.clone(), Object::Ref(*r)); - } - res.set(b"XObject".to_vec(), Object::Dict(xo)); - } - let (_, cleaned_data) = page_streams( - doc, - &{ - let mut cleaned_page = page.dict.clone(); - cleaned_page.set(b"Contents".to_vec(), Object::Array(new_list.clone())); - cleaned_page - }, - pi, - )?; - retired_properties.extend(interp::prune_properties(doc, &mut res, &cleaned_data)?); - type3::prune_type3_from_resources(doc, &mut res, &cleaned_data)?; - doc.update_dict(page.obj, |d| { - d.set(b"Contents".to_vec(), Object::Array(new_list)); - d.set(b"Resources".to_vec(), Object::Dict(res)); - })?; - // Tags must not keep what the page no longer shows. - let after = doc.get(page.obj).as_dict().cloned().unwrap_or_default(); - let (_, new_data) = page_streams(doc, &after, pi)?; - let (changed_ids, empty_ids) = - tags::touched(doc, &resources, pdfcraft_content::Pieces::join(&data).data(), pdfcraft_content::Pieces::join(&new_data).data()); - report.tags += tags::clean(doc, page.obj, None, &changed_ids, &empty_ids)?; - } - - for (old, new, changed, empty) in out.tag_changes { - report.tags += tags::clean(doc, page.obj, Some((old, new)), &changed, &empty)?; - } - - // 2. Annotations: the marks, and whatever lies under them (with their pop-ups). - let annots = annots_of(doc, &doc.get(page.obj).as_dict().cloned().unwrap_or_default()); - let mut removed: Vec = Vec::new(); - for a in &annots { - let Some(r) = a.as_ref() else { continue }; - let obj = doc.get(r); - let Some(d) = obj.as_dict() else { continue }; - let subtype = d.name(b"Subtype").unwrap_or(b""); - if subtype == b"Redact" { - if page_marks.iter().any(|m| m.obj == r) { - removed.push(r); - } - continue; - } - if subtype == b"Popup" { - continue; - } - if rect_of(doc, d.get(b"Rect")).is_some_and(|b| rects.iter().any(|x| pdfcraft_content::overlaps(*x, b, 0.0))) { - removed.push(r); - if subtype == b"Widget" { - if let Some((_, field)) = widget_owner.iter().find(|(w, _)| *w == r) - && !doomed_fields.contains(field) - { - doomed_fields.push(*field); - } - } else { - report.annotations += 1; - } - } - } - // Replies (`/IRT`, ISO 32000-2 §12.5.6.2) to a removed annotation go too, and replies to - // those: a reply placed elsewhere would keep its parent — the comment and the QuadPoints - // of the redacted words — in the file through its `/IRT` (#2659). Each pass removes at - // least one more annotation of this page or stops, so it ends. - let replies: Vec<(ObjRef, ObjRef)> = annots - .iter() - .filter_map(Object::as_ref) - .filter_map(|r| { - let obj = doc.get(r); - let d = obj.as_dict()?; - (d.name(b"Subtype") != Some(b"Widget")).then_some(())?; - Some((r, d.get(b"IRT").and_then(Object::as_ref)?)) - }) - .collect(); - loop { - let before = removed.len(); - for (r, parent) in &replies { - if !removed.contains(r) && removed.contains(parent) { - removed.push(*r); - report.annotations += 1; - } - } - if removed.len() == before { - break; - } - } - removed_everywhere.extend(removed.iter().copied()); - // Pop-ups dropped with their parent below count too: a reply on another page may answer one. - for a in &annots { - let Some(r) = a.as_ref() else { continue }; - if doc.get(r).as_dict().and_then(|d| d.get(b"Parent")).and_then(Object::as_ref).is_some_and(|p| removed.contains(&p)) { - removed_everywhere.insert(r); - } - } - let kept: Vec = annots - .into_iter() - .filter(|a| { - let Some(r) = a.as_ref() else { return true }; - if removed.contains(&r) { - return false; - } - // Pop-ups of removed annotations go too. - let obj = doc.get(r); - !obj.as_dict().and_then(|d| d.get(b"Parent")).and_then(Object::as_ref).is_some_and(|p| removed.contains(&p)) - }) - .collect(); - doc.update_dict(page.obj, |d| { - if kept.is_empty() { - d.remove(b"Annots"); - } else { - d.set(b"Annots".to_vec(), Object::Array(kept)); - } - })?; + redact_page_content(doc, &mut applying, pi, &page, &rects)?; + remove_page_annotations(doc, &mut applying, &page, &rects, page_marks)?; + } + if applying.images.refused() > 0 { + return Err(RedactError::ImagesPastAllowance); } + let Applying { mut report, doomed_fields, doomed_xobjects, retired_properties, mut removed_everywhere, mut images, .. } = applying; // 2b. Replies and pop-ups of a removed annotation can be listed on another page than their // parent: their `/IRT` or `/Parent` would keep the removed comment reachable. @@ -678,6 +532,7 @@ fn apply_to(doc: &mut Document, pages: Option<&[usize]>) -> Result) -> Result 0 { @@ -702,6 +559,285 @@ fn apply_to(doc: &mut Document, pages: Option<&[usize]>) -> Result Result { + let mut globals = HashSet::new(); + let mut seen = HashSet::new(); + let mut stack: Vec = doomed.to_vec(); + while let Some(r) = stack.pop() { + if !seen.insert(r) || seen.len() > MAX_JBIG2_WALK { + continue; + } + let object = doc.get(r); + let Object::Stream(s) = &*object else { continue }; + if let Some(g) = doc.jbig2_globals_ref(s) { + globals.insert(g); + } + // A form's images, and its forms' (an image's /SMask is a stream of its own, found below). + let resources = s.dict.get(b"Resources").map(|r| doc.resolve(r)); + let xobjects = resources.as_deref().and_then(Object::as_dict).and_then(|d| d.get(b"XObject")).map(|x| doc.resolve(x)); + if let Some(x) = xobjects.as_deref().and_then(Object::as_dict) { + stack.extend(x.iter().filter_map(|(_, v)| v.as_ref())); + } + if let Some(m) = s.dict.get(b"SMask").and_then(Object::as_ref) { + stack.push(m); + } + } + if globals.is_empty() { + return Ok(0); + } + let sharing: Vec = doc + .scan_objects() + .filter(|(_, o)| matches!(&**o, Object::Stream(s) if doc.jbig2_globals_ref(s).is_some_and(|g| globals.contains(&g)))) + .map(|(r, _)| r) + .collect(); + let doomed: HashSet = doomed.iter().copied().collect(); + let (mut restored, mut stuck, mut past_allowance) = (0, 0, false); + for r in sharing { + let object = doc.get(r); + let Object::Stream(s) = &*object else { continue }; + let refused = images.refused(); + match image::without_globals(doc, s, images) { + Some(new) => { + doc.set(r, Object::Stream(new)); + restored += usize::from(!doomed.contains(&r)); + } + None if doomed.contains(&r) => {} + None if images.refused() > refused => past_allowance = true, + None => stuck += 1, + } + } + if past_allowance { + return Err(RedactError::ImagesPastAllowance); + } + if stuck > 0 { + return Err(RedactError::SharedJbig2Globals(stuck)); + } + Ok(restored) +} + +/// What applying the marks collects across pages. +struct Applying { + report: Report, + doomed_fields: Vec, + /// The original image and form XObjects the rewrite drops or replaces, on any page. + doomed_xobjects: Vec, + retired_properties: Vec, + widget_owner: Vec<(ObjRef, ObjRef)>, + removed_everywhere: HashSet, + /// What clearing images may spend, across the run's pages. + images: pdfcraft_cos::ImageBudget, +} + +/// Step 1 for one page: rewrites its content so nothing readable stays under a mark, and cleans the tags it no longer shows. +fn redact_page_content( + doc: &mut Document, + applying: &mut Applying, + pi: usize, + page: &pdfcraft_model::Page, + rects: &[[f64; 4]], +) -> Result<(), RedactError> { + let Applying { report, doomed_xobjects, retired_properties, images, .. } = applying; + // 1. Content. + let mut budget = pdfcraft_cos::DecodeBudget::page(); + let (list, data) = page_streams_within(doc, &page.dict, pi, &mut budget)?; + let resources = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); + let out = { + let mut scope = Scope::new(rects, Mode::Apply, report, budget, images); + process(doc, &mut scope, &data, &resources, pdfcraft_content::Matrix::IDENTITY) + }; + // Fail closed: the interpreter lays vertical-writing text out horizontally, so it cannot + // tell whether those glyphs lie under a mark. Rather than report success and possibly + // leave the text in place, refuse the whole operation (the caller keeps the previous + // document). The error does not claim the text was under the mark. Full vertical-text + // removal is a future enhancement. + if let Some(error) = out.error { + return Err(error.into()); + } + if out.vertical { + return Err(RedactError::VerticalText(pi + 1)); + } + // An inline image the renderer reads no image for (no `EI` it accepts, or parameters it + // can't read): where the page's operators resume after it is a guess, so neither removal + // nor the verify pass can be trusted on this page. + if out.ambiguous { + return Err(RedactError::Unreadable(pi + 1)); + } + doomed_xobjects.extend_from_slice(&out.doomed); + retired_properties.extend(out.retired_properties); + let mut new_list = list.clone(); + let mut changed = false; + for (i, new) in out.streams.into_iter().enumerate() { + let Some(bytes) = new else { continue }; + let mut dict = match &*doc.resolve(&list[i]) { + Object::Stream(s) => s.dict.clone(), + _ => Dict::new(), + }; + dict.remove(b"Length"); + // Edit ▸ Add content keeps an added item's source text in `/PCAdded`; once its glyphs + // are redacted, keeping it would leave the redacted text in the file. The item stays + // as plain drawn content but is no longer editable. + dict.remove(b"PCAdded"); + // A new object: the original may be shared with other pages. + new_list[i] = Object::Ref(doc.add(Object::Stream(Stream::flate(dict, &bytes)))); + changed = true; + } + if changed || !out.xobjects.is_empty() || !out.replaced.is_empty() { + let mut res = resources.clone(); + if !out.xobjects.is_empty() || !out.replaced.is_empty() { + let mut xo = res.get(b"XObject").and_then(|x| doc.resolve(x).as_dict().cloned()).unwrap_or_default(); + // A full save keeps whatever is still referenced: unlink the originals this page no + // longer draws so the removed image or form bytes cannot survive in the saved file. + let page_content: Vec> = new_list + .iter() + .map(|o| match &*doc.resolve(o) { + Object::Stream(s) => s.decoded().unwrap_or_default(), + _ => Vec::new(), + }) + .collect(); + interp::prune(&mut xo, &out.replaced, &page_content); + for (n, r) in &out.xobjects { + xo.set(n.clone(), Object::Ref(*r)); + } + res.set(b"XObject".to_vec(), Object::Dict(xo)); + } + let (_, cleaned_data) = page_streams( + doc, + &{ + let mut cleaned_page = page.dict.clone(); + cleaned_page.set(b"Contents".to_vec(), Object::Array(new_list.clone())); + cleaned_page + }, + pi, + )?; + retired_properties.extend(interp::prune_properties(doc, &mut res, &cleaned_data)?); + type3::prune_type3_from_resources(doc, &mut res, &cleaned_data)?; + doc.update_dict(page.obj, |d| { + d.set(b"Contents".to_vec(), Object::Array(new_list)); + d.set(b"Resources".to_vec(), Object::Dict(res)); + })?; + // Tags must not keep what the page no longer shows. + let after = doc.get(page.obj).as_dict().cloned().unwrap_or_default(); + let (_, new_data) = page_streams(doc, &after, pi)?; + let (changed_ids, empty_ids) = + tags::touched(doc, &resources, pdfcraft_content::Pieces::join(&data).data(), pdfcraft_content::Pieces::join(&new_data).data()); + report.tags += tags::clean(doc, page.obj, None, &changed_ids, &empty_ids)?; + } + + for (old, new, changed, empty) in out.tag_changes { + report.tags += tags::clean(doc, page.obj, Some((old, new)), &changed, &empty)?; + } + Ok(()) +} + +/// Step 2 for one page: the marks, and whatever lies under them (with their pop-ups). +fn remove_page_annotations( + doc: &mut Document, + applying: &mut Applying, + page: &pdfcraft_model::Page, + rects: &[[f64; 4]], + page_marks: &[&Mark], +) -> Result<(), RedactError> { + let Applying { report, doomed_fields, widget_owner, removed_everywhere, .. } = applying; + // 2. Annotations: the marks, and whatever lies under them (with their pop-ups). + let annots = annots_of(doc, &doc.get(page.obj).as_dict().cloned().unwrap_or_default()); + let mut removed: Vec = Vec::new(); + for a in &annots { + let Some(r) = a.as_ref() else { continue }; + let obj = doc.get(r); + let Some(d) = obj.as_dict() else { continue }; + let subtype = d.name(b"Subtype").unwrap_or(b""); + if subtype == b"Redact" { + if page_marks.iter().any(|m| m.obj == r) { + removed.push(r); + } + continue; + } + if subtype == b"Popup" { + continue; + } + if rect_of(doc, d.get(b"Rect")).is_some_and(|b| rects.iter().any(|x| pdfcraft_content::overlaps(*x, b, 0.0))) { + removed.push(r); + if subtype == b"Widget" { + if let Some((_, field)) = widget_owner.iter().find(|(w, _)| *w == r) + && !doomed_fields.contains(field) + { + doomed_fields.push(*field); + } + } else { + report.annotations += 1; + } + } + } + // Replies (`/IRT`, ISO 32000-2 §12.5.6.2) to a removed annotation go too, and replies to + // those: a reply placed elsewhere would keep its parent — the comment and the QuadPoints + // of the redacted words — in the file through its `/IRT` (#2659). Each pass removes at + // least one more annotation of this page or stops, so it ends. + let replies: Vec<(ObjRef, ObjRef)> = annots + .iter() + .filter_map(Object::as_ref) + .filter_map(|r| { + let obj = doc.get(r); + let d = obj.as_dict()?; + (d.name(b"Subtype") != Some(b"Widget")).then_some(())?; + Some((r, d.get(b"IRT").and_then(Object::as_ref)?)) + }) + .collect(); + loop { + let before = removed.len(); + for (r, parent) in &replies { + if !removed.contains(r) && removed.contains(parent) { + removed.push(*r); + report.annotations += 1; + } + } + if removed.len() == before { + break; + } + } + removed_everywhere.extend(removed.iter().copied()); + // Pop-ups dropped with their parent below count too: a reply on another page may answer one. + for a in &annots { + let Some(r) = a.as_ref() else { continue }; + if doc.get(r).as_dict().and_then(|d| d.get(b"Parent")).and_then(Object::as_ref).is_some_and(|p| removed.contains(&p)) { + removed_everywhere.insert(r); + } + } + let kept: Vec = annots + .into_iter() + .filter(|a| { + let Some(r) = a.as_ref() else { return true }; + if removed.contains(&r) { + return false; + } + // Pop-ups of removed annotations go too. + let obj = doc.get(r); + !obj.as_dict().and_then(|d| d.get(b"Parent")).and_then(Object::as_ref).is_some_and(|p| removed.contains(&p)) + }) + .collect(); + doc.update_dict(page.obj, |d| { + if kept.is_empty() { + d.remove(b"Annots"); + } else { + d.set(b"Annots".to_vec(), Object::Array(kept)); + } + })?; + Ok(()) +} + /// Remove redaction marks without applying them (`None` = all), with their replies and pop-ups as /// deleting a comment does: a reply left behind would keep its mark in the file through `/IRT`. /// Returns how many marks went. diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs index 98703dfbf2..6fd4354300 100644 --- a/crates/redact/src/sanitize.rs +++ b/crates/redact/src/sanitize.rs @@ -369,7 +369,8 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re let resources = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); let mut report = Report::default(); let mode = if write { Mode::Apply } else { Mode::Verify }; - let mut scope = Scope::new(&[], mode, &mut report, budget); + let mut images = pdfcraft_cos::ImageBudget::new(doc); + let mut scope = Scope::new(&[], mode, &mut report, budget, &mut images); if text { let c = page.crop(doc); scope.hidden_text = Some([c[0].min(c[2]), c[1].min(c[3]), c[0].max(c[2]), c[1].max(c[3])]); diff --git a/crates/redact/src/tags.rs b/crates/redact/src/tags.rs index 961f016e94..648c0c29e5 100644 --- a/crates/redact/src/tags.rs +++ b/crates/redact/src/tags.rs @@ -2,7 +2,7 @@ //! //! Marked content (MCIDs) that applying redactions changed is found by comparing each MCID's //! operators before and after. Elements owning such content lose their alternate text, actual -//! text and expansion (which may spell out the removed words); +//! text, title and expansion (which may spell out the removed words), as do their ancestors; //! references to marked content that is now empty are dropped from the element. use std::collections::{HashMap, HashSet}; @@ -281,7 +281,7 @@ pub(crate) fn clean_within( } doc.update_dict(r, |e| { if sensitive { - for key in [&b"Alt"[..], b"ActualText", b"E"] { + for key in DESCRIPTIONS { e.remove(key); } } @@ -321,8 +321,10 @@ impl Above { } } -/// The keys of an element that describe its content. -const DESCRIPTIONS: [&[u8]; 3] = [b"Alt", b"ActualText", b"E"]; +/// The keys of an element that describe its content, and so may repeat what was removed: one +/// policy for the element that owned it, the elements above it and elements written in place. +/// The title (`/T`) is included, as producers often fill it with the element's text. +const DESCRIPTIONS: [&[u8]; 4] = [b"Alt", b"ActualText", b"T", b"E"]; /// Each element's parents as the walk found them: through `/K`, which `/P` may not agree with. struct Parents(HashMap>); @@ -539,7 +541,7 @@ pub(crate) fn drop_object_refs_within(doc: &mut Document, gone: &HashSet // An element that lost an annotation keeps no text describing it, even with other // kids left: a link's alternate text often repeats the URL, an annotation's its // comment. Elements above it may describe it too. - for key in [&b"Alt"[..], b"ActualText", b"T", b"E"] { + for key in DESCRIPTIONS { e.remove(key); } })?; diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index c072d19db7..3c170aba20 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -52,7 +52,8 @@ fn under(doc: &mut Document, page: usize, rects: &[[f64; 4]]) -> usize { let (_, data) = page_streams_within(doc, &p.dict, page, &mut budget).unwrap(); let res = p.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); let mut rep = Report::default(); - let mut scope = Scope::new(rects, Mode::Verify, &mut rep, budget); + let mut images = pdfcraft_cos::ImageBudget::with_work(0); + let mut scope = Scope::new(rects, Mode::Verify, &mut rep, budget, &mut images); process(doc, &mut scope, &data, &res, pdfcraft_content::Matrix::IDENTITY).residue } @@ -519,13 +520,25 @@ fn two_pages_one_resource_dictionary(inherited: bool, again: bool) -> Document { ]) } -/// The samples of the image a page's (possibly inherited) resources name `name`. +/// What image `s` decodes to, its fax or JBIG2 data included, with no limit on the work: the +/// samples an operation would read through its `ImageBudget`. +fn decoded_image(doc: &Document, s: &pdfcraft_cos::Stream) -> Result, pdfcraft_cos::CosError> { + pdfcraft_cos::ImageBudget::with_work(u64::MAX).decode(doc, s, pdfcraft_cos::MAX_DECODED) +} + +/// The samples of the image a page's (possibly inherited) resources name `name`, decoded through +/// its image codec too ([`decoded_image`]). fn named_image(doc: &Document, page: usize, name: &[u8]) -> Option> { + named_stream(doc, page, name).and_then(|s| decoded_image(doc, &s).ok()) +} + +/// The image a page's (possibly inherited) resources name `name`. +fn named_stream(doc: &Document, page: usize, name: &[u8]) -> Option { let p = pdfcraft_model::pages(doc).swap_remove(page); let res = doc.resolve(p.dict.get(b"Resources")?).as_dict().cloned()?; let xo = doc.resolve(res.get(b"XObject")?).as_dict().cloned()?; match &*doc.get(xo.get(name)?.as_ref()?) { - Object::Stream(s) => s.decoded().ok(), + Object::Stream(s) => Some(s.clone()), _ => None, } } @@ -549,6 +562,765 @@ fn redaction_clears_pixels_intersected_without_covering_their_centres() { } } +/// An image whose PNG predictor is given by reference has its covered pixel cleared, and only +/// that one, whichever part is the reference: the dictionary, an array's entry, or the number +/// itself. `Stream::decoded` reads only a direct `/DecodeParms` with direct numbers, so the row +/// tags stayed in the data, the cleared byte drifted off the covered pixel and the covered sample +/// survived. +#[test] +fn an_image_whose_predictor_is_given_by_reference_has_the_covered_pixel_cleared() { + // Two rows of four grey samples, each behind a PNG "None" row tag. + let predicted = pdfcraft_filters::encode_flate(&[0, 10, 20, 30, 40, 0, 50, 60, 70, 80]); + let parms = "/Predictor 15 /Colors 1 /BitsPerComponent 8 /Columns 4"; + for (decode_parms, object8) in [ + ("8 0 R".to_owned(), format!("<< {parms} >>")), + ("[8 0 R]".to_owned(), format!("<< {parms} >>")), + ("<< /Predictor 8 0 R /Colors 1 /BitsPerComponent 8 /Columns 4 >>".to_owned(), "15".to_owned()), + ] { + let image = format!( + "/Type /XObject /Subtype /Image /Width 4 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /FlateDecode /DecodeParms {decode_parms}" + ); + let mut doc = one_page(b"q 40 0 0 20 0 0 cm /Im Do Q", "/XObject << /Im 7 0 R >>", vec![stream(&image, &predicted), object8.into_bytes()]); + // The top row's second sample. + mark(&mut doc, 0, &[[12.0, 12.0, 18.0, 18.0]], ""); + let report = apply(&mut doc, None).unwrap(); + assert_eq!(report.images_cleared, 1, "{decode_parms}"); + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert_eq!(named_image(&saved, 0, b"PCRedacted1"), Some(vec![10, 0, 30, 40, 50, 60, 70, 80]), "{decode_parms}"); + } +} + +/// A CCITT fax image (a scan the optimizer or Create PDF stored as Group 4) has its covered +/// pixels cleared in a copy, like any image PdfCraft can decode, instead of being removed whole: +/// whether it sits behind another filter, with its parameters given by reference, or is a mask, +/// its decode array inverting it given directly, by reference or with its first number so. +#[test] +fn a_fax_image_has_its_covered_pixels_cleared_instead_of_being_removed() { + // 16 × 2, all 1 bits (white), or all 0 bits (a mask painting everywhere); the left half of + // the top row is covered. + let g4 = pdfcraft_filters::encode_g4(&[0xFF; 4], 16, 2).unwrap(); + let painted = pdfcraft_filters::encode_g4(&[0x00; 4], 16, 2).unwrap(); + let hex: Vec = g4.iter().map(|b| format!("{b:02x}")).collect::().bytes().chain(*b">").collect(); + let image = "/Type /XObject /Subtype /Image /Width 16 /Height 2 /BitsPerComponent 1"; + let parms = "<< /K -1 /Columns 16 /Rows 2 >>"; + for (dict, data, extra, cleared) in [ + (format!("{image} /ColorSpace /DeviceGray /Filter /CCITTFaxDecode /DecodeParms {parms}"), g4.clone(), vec![], [0x00, 0xFF, 0xFF, 0xFF]), + ( + format!("{image} /ColorSpace /DeviceGray /Filter [/ASCIIHexDecode /CCITTFaxDecode] /DecodeParms [null 8 0 R]"), + hex, + vec![parms.as_bytes().to_vec()], + [0x00, 0xFF, 0xFF, 0xFF], + ), + // A mask's 0 bits paint: cleared bits are set to 1, which paints nothing. + (format!("{image} /ImageMask true /Filter /CCITTFaxDecode /DecodeParms {parms}"), painted, vec![], [0xFF, 0x00, 0x00, 0x00]), + // With /Decode [1 0] a mask's 1 bits paint, and cleared bits are 0. + (format!("{image} /ImageMask true /Decode [1 0] /Filter /CCITTFaxDecode /DecodeParms {parms}"), g4.clone(), vec![], [0x00, 0xFF, 0xFF, 0xFF]), + ( + format!("{image} /ImageMask true /Decode 8 0 R /Filter /CCITTFaxDecode /DecodeParms {parms}"), + g4.clone(), + vec![b"[1 0]".to_vec()], + [0x00, 0xFF, 0xFF, 0xFF], + ), + ( + format!("{image} /ImageMask true /Decode [8 0 R 0] /Filter /CCITTFaxDecode /DecodeParms {parms}"), + g4.clone(), + vec![b"1".to_vec()], + [0x00, 0xFF, 0xFF, 0xFF], + ), + ] { + let mut objs = vec![stream(&dict, data)]; + objs.extend(extra); + let mut doc = one_page(b"q 160 0 0 20 0 0 cm /Im Do Q", "/XObject << /Im 7 0 R >>", objs); + mark(&mut doc, 0, &[[0.0, 12.0, 75.0, 18.0]], ""); + let report = apply(&mut doc, None).unwrap(); + assert_eq!((report.images_cleared, report.images_removed), (1, 0), "{dict}"); + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert_eq!(named_image(&saved, 0, b"PCRedacted1").as_deref(), Some(&cleared[..]), "{dict}"); + } +} + +/// Images whose data is more than twice their raster before its last filter are cleared, not +/// taken for decompression bombs: grey stored as ASCIIHex text broken into lines under Flate (two +/// digits a byte and a line break every 64), and RGB behind an array of filters, which a fax +/// image's bound (a byte a pixel) would refuse. +#[test] +fn images_behind_ascii_hex_in_lines_or_a_filter_array_are_cleared() { + let (w, h) = (1000usize, 200usize); + let grey: Vec = (0..w * h).map(|i| (i % 251) as u8).collect(); + let digits: Vec = grey.iter().flat_map(|b| format!("{b:02X}").into_bytes()).collect(); + let mut lines: Vec = digits.chunks(64).flat_map(|line| line.iter().copied().chain(*b"\n")).collect(); + lines.push(b'>'); + let rgb: Vec = (0..100 * 100 * 3).map(|i| (i % 253) as u8).collect(); + let rgb_hex: Vec = pdfcraft_filters::encode_flate(&rgb).iter().flat_map(|b| format!("{b:02x}").into_bytes()).chain(*b">").collect(); + for (dict, data) in [ + ( + format!( + "/Type /XObject /Subtype /Image /Width {w} /Height {h} /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter [/FlateDecode /ASCIIHexDecode]" + ), + pdfcraft_filters::encode_flate(&lines), + ), + ( + "/Type /XObject /Subtype /Image /Width 100 /Height 100 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter [/ASCIIHexDecode /FlateDecode]" + .to_string(), + rgb_hex, + ), + ] { + let mut doc = one_page(b"q 100 0 0 20 0 0 cm /Im Do Q", "/XObject << /Im 7 0 R >>", vec![stream(&dict, data)]); + mark(&mut doc, 0, &[[0.0, 0.0, 10.0, 10.0]], ""); + let report = apply(&mut doc, None).unwrap(); + assert_eq!((report.images_cleared, report.images_removed), (1, 0), "{dict}"); + } +} + +/// A fax image that can't be decoded in full is removed whole, as any other image is: one whose +/// code is cut short, and one behind a filter that inflates past what a code for its pixels can +/// take (a decompression bomb, which `decoded_image` alone would undo up to a gigabyte). +#[test] +fn a_fax_image_that_cannot_be_decoded_in_full_is_removed_whole() { + let g4 = pdfcraft_filters::encode_g4(&[0xFF; 4], 16, 2).unwrap(); + // A busy picture's code, cut off in its first row. + let busy = pdfcraft_filters::encode_g4(&[0xAA, 0xAA, 0x55, 0x55], 16, 2).unwrap(); + let short = busy.get(..1).unwrap().to_vec(); + // The real code, then four megabytes of padding the fax decoder would never read. + let mut padded = g4.clone(); + padded.resize(4 << 20, 0); + let bomb = pdfcraft_filters::encode_flate(&padded); + let image = "/Type /XObject /Subtype /Image /Width 16 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 1"; + let parms = "<< /K -1 /Columns 16 /Rows 2 >>"; + for (filters, data) in [("/CCITTFaxDecode /DecodeParms", short), ("[/FlateDecode /CCITTFaxDecode] /DecodeParms [null", bomb)] { + let close = if filters.ends_with("[null") { "]" } else { "" }; + let dict = format!("{image} /Filter {filters} {parms}{close}"); + let mut doc = one_page(b"q 160 0 0 20 0 0 cm /Im Do Q", "/XObject << /Im 7 0 R >>", vec![stream(&dict, data)]); + mark(&mut doc, 0, &[[0.0, 12.0, 75.0, 18.0]], ""); + let report = apply(&mut doc, None).unwrap(); + assert_eq!((report.images_cleared, report.images_removed), (0, 1), "{dict}"); + } +} + +/// Group 3 on bytes is removed whole: the renderer draws some of its layouts as other rows than +/// the decoder reads, so the pixels a mark covers on screen needn't be the ones cleared. Group 3 +/// off bytes and Group 4 on bytes are cleared. Each picture is 16 × 2 white, each row a white run +/// of 16 (`101010`): off bytes; on bytes with no end-of-line codes; on bytes after codes whose fill +/// ends each on a byte, at `/K 0` and at `/K 1` (a 1 after each code), `/EncodedByteAlign` given by +/// reference there; and Group 4 rows on bytes, each the one code 1 (vertical, no change). +#[test] +fn group_3_on_bytes_is_removed_whole_and_other_fax_images_cleared() { + let image = "/Type /XObject /Subtype /Image /Width 16 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 1 /Filter /CCITTFaxDecode"; + for (k, on_bytes, parms, data, extra, removed) in [ + (0, false, "<< /K 0 /Columns 16 >>", vec![0xAA, 0xA0], vec![], false), + (0, true, "<< /K 0 /Columns 16 /EncodedByteAlign true >>", vec![0xA8, 0xA8], vec![], true), + (0, true, "<< /K 0 /Columns 16 /EncodedByteAlign true >>", vec![0x00, 0x01, 0xA8, 0x00, 0x01, 0xA8], vec![], true), + (1, true, "<< /K 1 /Columns 16 /EncodedByteAlign 8 0 R >>", vec![0x00, 0x01, 0xD4, 0x00, 0x01, 0xD4], vec![b"true".to_vec()], true), + (-1, true, "<< /K -1 /Columns 16 /EncodedByteAlign true >>", vec![0x80, 0x80], vec![], false), + ] { + let p = pdfcraft_filters::CcittParams { k, columns: 16, encoded_byte_align: on_bytes, ..Default::default() }; + let mut work = u64::MAX; + assert_eq!(pdfcraft_filters::decode_ccitt_within(&data, &p, 2, &mut work).unwrap(), [0xFF; 4], "{parms}: the picture is white"); + let dict = format!("{image} /DecodeParms {parms}"); + let mut objs = vec![stream(&dict, data)]; + objs.extend(extra); + let mut doc = one_page(b"q 160 0 0 20 0 0 cm /Im Do Q", "/XObject << /Im 7 0 R >>", objs); + mark(&mut doc, 0, &[[0.0, 12.0, 75.0, 18.0]], ""); + let report = apply(&mut doc, None).unwrap(); + if removed { + assert_eq!((report.images_cleared, report.images_removed), (0, 1), "{parms}"); + } else { + assert_eq!((report.images_cleared, report.images_removed), (1, 0), "{parms}"); + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert_eq!(named_image(&saved, 0, b"PCRedacted1").as_deref(), Some(&[0x00, 0xFF, 0xFF, 0xFF][..]), "{parms}"); + } + } +} + +/// A busy fax image is cleared, though its code is bigger than three times its raster (the bound +/// for other images): a code takes up to about a byte per pixel, and the fax image gets that much. +#[test] +fn a_busy_fax_image_behind_another_filter_is_cleared() { + // 256 × 256, rows of a one-pixel checkerboard between white ones: each checkered row is coded + // afresh against the white one above it, about four bits a pixel. + let rows: Vec = (0..256).flat_map(|y| [if y % 2 == 0 { 0xAA } else { 0xFF }; 32]).collect(); + let g4 = pdfcraft_filters::encode_g4(&rows, 256, 256).unwrap(); + let raster = 32 * 256; + assert!(g4.len() > (raster + 256) * 3 + 4096, "{} bytes: within the bound for other images", g4.len()); + let dict = "/Type /XObject /Subtype /Image /Width 256 /Height 256 /ColorSpace /DeviceGray /BitsPerComponent 1 \ + /Filter [/FlateDecode /CCITTFaxDecode] /DecodeParms [null << /K -1 /Columns 256 /Rows 256 >>]"; + let mut doc = one_page(b"q 256 0 0 256 0 0 cm /Im Do Q", "/XObject << /Im 7 0 R >>", vec![stream(dict, pdfcraft_filters::encode_flate(&g4))]); + mark(&mut doc, 0, &[[0.0, 0.0, 10.0, 10.0]], ""); + let report = apply(&mut doc, None).unwrap(); + assert_eq!((report.images_cleared, report.images_removed), (1, 0)); +} + +/// After a save no stream holds the pixels under a mark, read as it would be shown, JBIG2 included, +/// on a scan coded with symbols and on one coded as a generic region. `COVERED` and `KEPT` are pixels +/// found nowhere else in the image, one lot under the mark and one beside it, so the search is seen +/// to find what stays. The one letter over and over, lower down, is what makes symbols worth coding. +/// The picture saved is the one with every pixel under the mark cleared and no other changed, so a +/// search that one cleared byte would blind isn't all that holds this. +#[test] +fn no_stream_holds_the_pixels_under_a_mark_on_a_jbig2_scan() { + const COVERED: [u8; 8] = [0x0F, 0x3C, 0x5A, 0xC3, 0x96, 0x69, 0xA5, 0x1E]; + const KEPT: [u8; 8] = [0x87, 0x2D, 0x4B, 0xE1, 0x78, 0xD2, 0xB4, 0x1F]; + const LETTER: [&str; 7] = ["#####", "#....", "####.", "#....", "#....", "#....", "#####"]; + let (w, h) = (512usize, 64usize); + let stride = w / 8; + let mut bits = vec![0xFFu8; stride * h]; + bits[stride * 2..stride * 2 + 8].copy_from_slice(&COVERED); + bits[stride * 2 + 16..stride * 2 + 24].copy_from_slice(&KEPT); + for top in (16..h - 8).step_by(10) { + for x in (4..w - 8).step_by(8) { + for (dy, row) in LETTER.iter().enumerate() { + for (dx, _) in row.bytes().enumerate().filter(|(_, c)| *c == b'#') { + bits[(top + dy) * stride + (x + dx) / 8] &= !(0x80 >> ((x + dx) % 8)); + } + } + } + } + let holds = |doc: &Document, pixels: &[u8]| { + doc.scan_objects().any(|(_, object)| match &*object { + Object::Stream(s) => decoded_image(doc, s).is_ok_and(|d| d.windows(pixels.len()).any(|w| w == pixels)), + _ => false, + }) + }; + // Columns 0 to 99 of rows 0 to 7. + let mut cleared = bits.clone(); + for row in cleared.chunks_mut(stride).take(8) { + row[..12].fill(0); + row[12] &= 0x0F; + } + let symbols = pdfcraft_filters::encode_jbig2(&bits, w as u32, h as u32).unwrap(); + let generic = pdfcraft_filters::encode_jbig2_generic(&bits, w as u32, h as u32).unwrap(); + assert!(symbols.len() < generic.len(), "symbol coding has to be what this tests"); + for coded in [symbols, generic] { + let dict = format!("/Type /XObject /Subtype /Image /Width {w} /Height {h} /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter /JBIG2Decode"); + // Half a point a pixel, its top row at y 132. + let mut doc = one_page(b"q 256 0 0 32 0 100 cm /Im Do Q", "/XObject << /Im 7 0 R >>", vec![stream(&dict, coded)]); + assert!(holds(&doc, &COVERED) && holds(&doc, &KEPT), "the pixels are there to be found"); + mark(&mut doc, 0, &[[0.25, 128.25, 49.75, 132.0]], ""); + assert_eq!(apply(&mut doc, None).unwrap().images_cleared, 1); + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert!(!holds(&saved, &COVERED), "the covered pixels stayed in the file"); + assert!(holds(&saved, &KEPT), "the pixels beside them stay"); + assert!(holds(&saved, &cleared), "every pixel under the mark is cleared, and no other changed"); + } +} + +/// `/JBIG2Globals` data a decoder skips: one extension segment (ISO/IEC 14492 7.4.14) of a type it +/// needn't read, holding `secret`; its 11-byte header gives the length of the data after it. +fn jbig2_globals(secret: &[u8]) -> Vec { + let mut g = vec![0, 0, 0, 0, 0x3E, 0, 0]; + g.extend_from_slice(&u32::try_from(4 + secret.len()).unwrap().to_be_bytes()); + g.extend_from_slice(&[0, 0, 0, 1]); + g.extend_from_slice(secret); + g +} + +/// An image the run's allowance can't decode can't be cleared. Removed whole, like an image that +/// can't be read, it would take what no mark covers with it without a word, so the run stops there, +/// and [`apply`] leaves the document as it was. +#[test] +fn a_jbig2_image_past_the_runs_allowance_stops_the_run() { + let mut dict = Dict::new(); + dict.set(b"Width".to_vec(), Object::Int(16)); + dict.set(b"Height".to_vec(), Object::Int(2)); + dict.set(b"BitsPerComponent".to_vec(), Object::Int(1)); + dict.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + dict.set(b"Filter".to_vec(), Object::name("JBIG2Decode")); + let image = Stream::from_raw(dict, pdfcraft_filters::encode_jbig2_generic(&[0xFF; 4], 16, 2).unwrap()); + let doc = Document::new_empty(); + let half = [[0.0, 0.0, 0.5, 1.0]]; + let clear = |work| crate::image::clear(&doc, &image, &pdfcraft_content::Matrix::IDENTITY, &half, &mut pdfcraft_cos::ImageBudget::with_work(work)); + assert!(matches!(clear(u64::MAX), Ok(Some(_))), "cleared in a copy"); + assert!(matches!(clear(0), Err(())), "it can't be cleared"); + + let coded = pdfcraft_filters::encode_jbig2_generic(&[0xFF; 4], 16, 2).unwrap(); + let dict = "/Type /XObject /Subtype /Image /Width 16 /Height 2 /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter /JBIG2Decode"; + let mut doc = one_page(b"q 160 0 0 20 0 0 cm /Im Do Q", "/XObject << /Im 7 0 R >>", vec![stream(dict, coded)]); + mark(&mut doc, 0, &[[0.0, 12.0, 75.0, 18.0]], ""); + let refused = crate::apply_within(&mut doc, None, pdfcraft_cos::ImageBudget::with_work(0)); + assert!(matches!(refused, Err(crate::RedactError::ImagesPastAllowance)), "{refused:?}"); + let mut doc = one_page( + b"q 160 0 0 20 0 0 cm /Im Do Q", + "/XObject << /Im 7 0 R >>", + vec![stream(dict, pdfcraft_filters::encode_jbig2_generic(&[0xFF; 4], 16, 2).unwrap())], + ); + mark(&mut doc, 0, &[[0.0, 12.0, 75.0, 18.0]], ""); + assert!(crate::apply_within(&mut doc, None, pdfcraft_cos::ImageBudget::with_work(u64::MAX)).is_ok()); +} + +/// A Flate image past the run's allowance stops the run as a JBIG2 image does: removed whole, it +/// would take what no mark covers with it, unreported. A JPEG, which clearing doesn't read, is +/// removed whole as it always was, and spends nothing. +#[test] +fn a_flate_image_past_the_runs_allowance_stops_the_run() { + let dict = "/Type /XObject /Subtype /Image /Width 2 /Height 2 /BitsPerComponent 8 /ColorSpace /DeviceGray /Filter /FlateDecode"; + let page = || one_page(b"q 160 0 0 20 0 0 cm /Im Do Q", "/XObject << /Im 7 0 R >>", vec![stream(dict, pdfcraft_filters::encode_flate(&[9; 4]))]); + let mut doc = page(); + mark(&mut doc, 0, &[[0.0, 12.0, 75.0, 18.0]], ""); + let refused = crate::apply_within(&mut doc, None, pdfcraft_cos::ImageBudget::with_work(0)); + assert!(matches!(refused, Err(crate::RedactError::ImagesPastAllowance)), "{refused:?}"); + let mut doc = page(); + mark(&mut doc, 0, &[[0.0, 12.0, 75.0, 18.0]], ""); + assert!(crate::apply_within(&mut doc, None, pdfcraft_cos::ImageBudget::with_work(u64::MAX)).is_ok()); + + let dict = "/Type /XObject /Subtype /Image /Width 2 /Height 2 /BitsPerComponent 8 /ColorSpace /DeviceGray /Filter /DCTDecode"; + let mut doc = one_page(b"q 160 0 0 20 0 0 cm /Im Do Q", "/XObject << /Im 7 0 R >>", vec![stream(dict, b"not read")]); + mark(&mut doc, 0, &[[0.0, 12.0, 75.0, 18.0]], ""); + let report = crate::apply_within(&mut doc, None, pdfcraft_cos::ImageBudget::with_work(0)).unwrap(); + assert_eq!(report.images_removed, 1); +} + +/// A JBIG2 image (a scan the optimizer stored as JBIG2) has its covered pixels cleared in a copy, +/// like a fax image, instead of being removed whole: with `/JBIG2Globals`, its `/DecodeParms` by +/// reference, behind another filter with its globals at its own place, or as a mask, its decode +/// array inverting it or not. +#[test] +fn a_jbig2_image_has_its_covered_pixels_cleared_instead_of_being_removed() { + // 16 × 2, all 1 bits (white), or all 0 bits (a mask painting everywhere); the left half of + // the top row is covered. + let white = pdfcraft_filters::encode_jbig2_generic(&[0xFF; 4], 16, 2).unwrap(); + let painted = pdfcraft_filters::encode_jbig2_generic(&[0x00; 4], 16, 2).unwrap(); + let hex: Vec = white.iter().map(|b| format!("{b:02x}")).collect::().bytes().chain(*b">").collect(); + let globals = stream("", jbig2_globals(b"shared")); + let image = "/Type /XObject /Subtype /Image /Width 16 /Height 2 /BitsPerComponent 1"; + for (dict, data, extra, cleared) in [ + (format!("{image} /ColorSpace /DeviceGray /Filter /JBIG2Decode"), white.clone(), vec![], [0x00, 0xFF, 0xFF, 0xFF]), + ( + format!("{image} /ColorSpace /DeviceGray /Filter /JBIG2Decode /DecodeParms 8 0 R"), + white.clone(), + vec![b"<< /JBIG2Globals 9 0 R >>".to_vec(), globals.clone()], + [0x00, 0xFF, 0xFF, 0xFF], + ), + ( + format!("{image} /ColorSpace /DeviceGray /Filter [/ASCIIHexDecode /JBIG2Decode] /DecodeParms [null << /JBIG2Globals 8 0 R >>]"), + hex, + vec![globals.clone()], + [0x00, 0xFF, 0xFF, 0xFF], + ), + // `/Crypt` was undone on load, and takes a place in the parameters. + ( + format!("{image} /ColorSpace /DeviceGray /Filter [/Crypt /JBIG2Decode] /DecodeParms [null << /JBIG2Globals 8 0 R >>]"), + white.clone(), + vec![globals], + [0x00, 0xFF, 0xFF, 0xFF], + ), + // A mask's 0 bits paint: cleared bits are set to 1, which paints nothing. + (format!("{image} /ImageMask true /Filter /JBIG2Decode"), painted, vec![], [0xFF, 0x00, 0x00, 0x00]), + // With /Decode [1 0] a mask's 1 bits paint, and cleared bits are 0. + (format!("{image} /ImageMask true /Decode [1 0] /Filter /JBIG2Decode"), white, vec![], [0x00, 0xFF, 0xFF, 0xFF]), + ] { + let mut objs = vec![stream(&dict, data)]; + objs.extend(extra); + let mut doc = one_page(b"q 160 0 0 20 0 0 cm /Im Do Q", "/XObject << /Im 7 0 R >>", objs); + mark(&mut doc, 0, &[[0.0, 12.0, 75.0, 18.0]], ""); + let report = apply(&mut doc, None).unwrap(); + assert_eq!((report.images_cleared, report.images_removed), (1, 0), "{dict}"); + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert_eq!(named_image(&saved, 0, b"PCRedacted1").as_deref(), Some(&cleared[..]), "{dict}"); + } +} + +/// A JBIG2 image is removed whole when its covered pixels can't be cleared for certain: a JBIG2 +/// page taller than the image (the renderer draws the whole page into the image, so the rows cleared +/// wouldn't be the ones shown) +/// or shorter, or of another width; globals that can't be read; data cut short; an image of more +/// than one bit or component, which JBIG2 doesn't code, even when its rows take as many bytes; and +/// a page far past the image's raster, which isn't decoded. +#[test] +fn a_jbig2_image_that_cannot_be_cleared_for_certain_is_removed_whole() { + let white = |rows: u32| pdfcraft_filters::encode_jbig2_generic(&vec![0xFF; 2 * rows as usize], 16, rows).unwrap(); + // The page information (the first segment's data, after its 11-byte header) claiming 20,000 + // rows of 16. + let mut tall = white(2); + tall[15..19].copy_from_slice(&20_000u32.to_be_bytes()); + let image = |size: &str, more: &str| { + format!("/Type /XObject /Subtype /Image {size} /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter /JBIG2Decode {more}") + }; + let size = "/Width 16 /Height 2"; + for (dict, data, extra) in [ + (image(size, ""), white(3), vec![]), + (image("/Width 16 /Height 3", ""), white(2), vec![]), + (image("/Width 15 /Height 2", ""), white(2), vec![]), + (image(size, "/DecodeParms << /JBIG2Globals 8 0 R >>"), white(2), vec![stream("", b"not JBIG2")]), + (image(size, ""), white(2)[..20].to_vec(), vec![]), + (image(size, "").replace("/BitsPerComponent 1", "/BitsPerComponent 8"), white(2), vec![]), + (image(size, "").replace("/DeviceGray", "/DeviceRGB"), white(2), vec![]), + // Two pixels of RGB at a bit take a byte a row, as JBIG2's rows do. + ( + image("/Width 2 /Height 2", "").replace("/DeviceGray", "/DeviceRGB"), + pdfcraft_filters::encode_jbig2_generic(&[0xFF; 2], 2, 2).unwrap(), + vec![], + ), + (image(size, ""), tall, vec![]), + ] { + let mut objs = vec![stream(&dict, data)]; + objs.extend(extra); + let mut doc = one_page(b"q 160 0 0 20 0 0 cm /Im Do Q", "/XObject << /Im 7 0 R >>", objs); + mark(&mut doc, 0, &[[0.0, 12.0, 75.0, 18.0]], ""); + let report = apply(&mut doc, None).unwrap(); + assert_eq!((report.images_cleared, report.images_removed), (0, 1), "{dict}"); + } +} + +/// A scan stored as JBIG2 symbols, with globals, keeps every pixel outside the area once it is +/// redacted and saved, and the pixels under it are cleared; neither its JBIG2 code nor its globals, +/// which no other image uses, stay in the file. The cleared copy is coded afresh as JBIG2, smaller +/// than Flate, and refers to no globals. +#[test] +fn a_redacted_jbig2_scan_keeps_neither_its_code_nor_its_globals() { + const LETTERS: [[&str; 7]; 3] = [ + ["#####", "#....", "####.", "#....", "#....", "#....", "#####"], + [".###.", "#...#", "#....", "#....", "#....", "#...#", ".###."], + ["#...#", "#...#", "#####", "#...#", "#...#", "#...#", "#...#"], + ]; + let (w, h) = (400usize, 40usize); + let stride = w.div_ceil(8); + let mut bits = vec![0xFFu8; stride * h]; + for (line, top) in [2usize, 12, 22, 32].into_iter().enumerate() { + for (k, x) in (3..w - 6).step_by(7).enumerate() { + for (dy, row) in LETTERS[(k + line) % 3].iter().enumerate() { + for (dx, _) in row.bytes().enumerate().filter(|(_, c)| *c == b'#') { + bits[(top + dy) * stride + (x + dx) / 8] &= !(0x80 >> ((x + dx) % 8)); + } + } + } + } + let coded = pdfcraft_filters::encode_jbig2(&bits, w as u32, h as u32).unwrap(); + let original = coded.clone(); + assert!(coded.len() < pdfcraft_filters::encode_jbig2_generic(&bits, w as u32, h as u32).unwrap().len(), "symbol coded"); + let dict = format!( + "/Type /XObject /Subtype /Image /Width {w} /Height {h} /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter /JBIG2Decode /DecodeParms << /JBIG2Globals 8 0 R >>" + ); + // Half a point a pixel, its top row at y 120. + let mut doc = one_page( + b"q 200 0 0 20 0 100 cm /Im Do Q", + "/XObject << /Im 7 0 R >>", + vec![stream(&dict, coded), stream("", jbig2_globals(b"LEAK_GLOBALS"))], + ); + // Columns 0 to 99 of rows 0 to 12. + mark(&mut doc, 0, &[[0.25, 113.75, 49.75, 120.0]], ""); + let report = apply(&mut doc, None).unwrap(); + assert_eq!((report.images_cleared, report.images_removed), (1, 0)); + let mut cleared = bits.clone(); + for y in 0..=12 { + for x in 0..=99 { + cleared[y * stride + x / 8] &= !(0x80 >> (x % 8)); + } + } + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert_eq!(named_image(&saved, 0, b"PCRedacted1"), Some(cleared)); + assert_no_secret(&doc, b"LEAK_GLOBALS"); + let copy = named_stream(&saved, 0, b"PCRedacted1").unwrap(); + assert_eq!(copy.dict.name(b"Filter"), Some(&b"JBIG2Decode"[..])); + assert!(!copy.dict.contains(b"DecodeParms"), "the copy refers to no globals"); + let flate = pdfcraft_filters::encode_flate(&named_image(&saved, 0, b"PCRedacted1").unwrap()); + assert!(copy.raw.len() < flate.len(), "JBIG2 {} bytes, Flate {}", copy.raw.len(), flate.len()); + for (r, object) in saved.scan_objects() { + if let Object::Stream(s) = &*object { + assert_ne!(&s.raw[..], &original[..], "the original JBIG2 code stays as {r:?}"); + } + } +} + +/// `w` × `h` rows, `w` a multiple of 8: lines of word-sized dark blocks 14 rows tall, their edges +/// ragged by a sample from row to row, as a scan's are; Group 4 codes such rows from the row above. +fn ragged_scan(w: usize, h: usize) -> Vec { + let stride = w / 8; + let mut seed = 0x9E37_79B9_7F4A_7C15u64; + let mut next = move || { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + seed + }; + let mut bits = vec![0xFFu8; stride * h]; + for top in (3..h - 14).step_by(20) { + let mut x = 4; + while x + 70 < w { + let len = 20 + (next() % 40) as usize; + for y in top..top + 14 { + let (left, right) = (x + (next() % 2) as usize, x + len - (next() % 2) as usize); + for xx in left..right { + bits[y * stride + xx / 8] &= !(0x80 >> (xx % 8)); + } + } + x += len + 6 + (next() % 6) as usize; + } + } + bits +} + +/// A cleared one-bit image of one component, a scan as a rule, is stored as CCITT Group 4 when +/// that is smaller than Flate, whatever it was stored as (Flate here), and never as JBIG2 unless it +/// was JBIG2; an eight-bit image stays Flate. The copy decodes to the cleared samples. +#[test] +fn a_cleared_black_and_white_scan_is_stored_as_group_4() { + let (w, h) = (800usize, 200usize); + let stride = w / 8; + let bits = ragged_scan(w, h); + let g4 = pdfcraft_filters::encode_g4(&bits, w as u32, h as u32).unwrap(); + assert!(g4.len() < pdfcraft_filters::encode_flate(&bits).len(), "Group 4 is smaller than Flate here"); + let grey: Vec = (0..w * h).map(|i| (i % 251) as u8).collect(); + for (dict, data, filter) in [ + ( + format!("/Type /XObject /Subtype /Image /Width {w} /Height {h} /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter /FlateDecode"), + pdfcraft_filters::encode_flate(&bits), + &b"CCITTFaxDecode"[..], + ), + ( + format!("/Type /XObject /Subtype /Image /Width {w} /Height {h} /BitsPerComponent 8 /ColorSpace /DeviceGray /Filter /FlateDecode"), + pdfcraft_filters::encode_flate(&grey), + &b"FlateDecode"[..], + ), + ] { + let mut doc = one_page(b"q 200 0 0 50 0 100 cm /Im Do Q", "/XObject << /Im 7 0 R >>", vec![stream(&dict, data)]); + // The left quarter of the top half. + mark(&mut doc, 0, &[[0.0, 125.0, 50.0, 150.0]], ""); + assert_eq!(apply(&mut doc, None).unwrap().images_cleared, 1, "{dict}"); + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + let copy = named_stream(&saved, 0, b"PCRedacted1").unwrap(); + assert_eq!(copy.dict.name(b"Filter"), Some(filter), "{dict}"); + // Covered: rows 0 to 99 (y 150 down to 125, a quarter point a row), samples 0 to 199. + let mut expected = if filter == b"FlateDecode" { grey.clone() } else { bits.clone() }; + for y in 0..100 { + for x in 0..200 { + if filter == b"FlateDecode" { + expected[y * w + x] = 0; + } else { + expected[y * stride + x / 8] &= !(0x80 >> (x % 8)); + } + } + } + assert_eq!(named_image(&saved, 0, b"PCRedacted1").unwrap(), expected, "{dict}"); + } +} + +/// A cleared one-bit image is stored as the smallest stream, its dictionary counted: a tiny one, +/// whose Group 4 code is shorter than Flate's but whose `/DecodeParms` aren't, stays Flate. Rows +/// wider than Group 4 codes (`MAX_G4_COLUMNS`) stay Flate too, and still clear. +#[test] +fn a_cleared_image_group_4_wouldnt_shrink_stays_flate() { + let wide = pdfcraft_filters::MAX_G4_COLUMNS as usize + 1; + for (w, h) in [(3usize, 2usize), (wide, 2)] { + let stride = w.div_ceil(8); + assert_eq!(pdfcraft_filters::encode_g4(&vec![0xFF; stride * h], w as u32, h as u32).is_ok(), w != wide, "{w} × {h}"); + let dict = format!("/Type /XObject /Subtype /Image /Width {w} /Height {h} /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter /FlateDecode"); + let mut doc = one_page( + b"q 200 0 0 20 0 100 cm /Im Do Q", + "/XObject << /Im 7 0 R >>", + vec![stream(&dict, pdfcraft_filters::encode_flate(&vec![0xFF; stride * h]))], + ); + // The top row's left half. + mark(&mut doc, 0, &[[0.0, 112.0, 99.0, 118.0]], ""); + assert_eq!(apply(&mut doc, None).unwrap().images_cleared, 1, "{w} × {h}"); + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert_eq!(named_stream(&saved, 0, b"PCRedacted1").unwrap().dict.name(b"Filter"), Some(&b"FlateDecode"[..]), "{w} × {h}"); + let samples = named_image(&saved, 0, b"PCRedacted1").unwrap(); + let sample = |x: usize| samples.get(x / 8).is_some_and(|b| b & (0x80 >> (x % 8)) != 0); + assert!(!sample(0) && sample(w - 1), "{w} × {h}: the top row's first sample is cleared and its last kept"); + assert!(samples.get(stride..).is_some_and(|row| row.iter().all(|b| *b == 0xFF)), "{w} × {h}: the bottom row is kept"); + } +} + +/// Only one bit of one component goes to Group 4 or JBIG2. An 8-bit grey image here starts as white +/// as a bilevel scan and goes on as noise Flate can't shrink, so Group 4, which would read only the +/// first rows' worth of bytes, would look far smaller while losing the picture. +#[test] +fn a_cleared_image_of_more_than_one_bit_stays_flate() { + let (w, h) = (256usize, 256usize); + let mut data = vec![0xFF; w * h]; + let mut state = 0x1234_5678u32; + for b in data.iter_mut().skip(w * h / 8) { + state ^= state << 13; + state ^= state >> 17; + state ^= state << 5; + *b = state.to_le_bytes()[0]; + } + let dict = format!("/Type /XObject /Subtype /Image /Width {w} /Height {h} /BitsPerComponent 8 /ColorSpace /DeviceGray /Filter /FlateDecode"); + let mut doc = one_page(b"q 200 0 0 20 0 100 cm /Im Do Q", "/XObject << /Im 7 0 R >>", vec![stream(&dict, pdfcraft_filters::encode_flate(&data))]); + mark(&mut doc, 0, &[[0.0, 112.0, 99.0, 118.0]], ""); + assert_eq!(apply(&mut doc, None).unwrap().images_cleared, 1); + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert_eq!(named_stream(&saved, 0, b"PCRedacted1").unwrap().dict.name(b"Filter"), Some(&b"FlateDecode"[..])); + assert_eq!(named_image(&saved, 0, b"PCRedacted1").unwrap().get(h * w / 2..), data.get(h * w / 2..), "the rows below the mark are kept"); +} + +/// A copy stored as Group 4 or JBIG2 is read like any image: a second redaction, across a save, +/// clears it again rather than removing it whole, keeps what the first cleared, and stores it +/// smaller than Flate again. +#[test] +fn a_cleared_copy_stored_as_group_4_or_jbig2_can_be_redacted_again() { + let (w, h) = (800usize, 200usize); + let stride = w / 8; + let bits = ragged_scan(w, h); + let image = format!("/Type /XObject /Subtype /Image /Width {w} /Height {h} /BitsPerComponent 1 /ColorSpace /DeviceGray"); + for (filter, data, wanted) in [ + ("/FlateDecode", pdfcraft_filters::encode_flate(&bits), &b"CCITTFaxDecode"[..]), + ("/JBIG2Decode", pdfcraft_filters::encode_jbig2(&bits, w as u32, h as u32).unwrap(), &b"JBIG2Decode"[..]), + ] { + let dict = format!("{image} /Filter {filter}"); + let mut doc = one_page(b"q 200 0 0 50 0 100 cm /Im Do Q", "/XObject << /Im 7 0 R >>", vec![stream(&dict, data)]); + mark(&mut doc, 0, &[[0.0, 125.0, 50.0, 150.0]], ""); + assert_eq!(apply(&mut doc, None).unwrap().images_cleared, 1, "{filter}"); + let mut saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert_eq!(named_stream(&saved, 0, b"PCRedacted1").unwrap().dict.name(b"Filter"), Some(wanted), "{filter}"); + mark(&mut saved, 0, &[[150.0, 100.0, 200.0, 125.0]], ""); + let report = apply(&mut saved, None).unwrap(); + assert_eq!((report.images_cleared, report.images_removed), (1, 0), "{filter}: cleared again, not removed"); + let again = Document::open(Arc::new(write_full(&saved, &SaveOptions::default()).unwrap())).unwrap(); + // Rows 0 to 99 of the left quarter, then 100 to 199 of the right. + let mut expected = bits.clone(); + for (rows, columns) in [(0..100, 0..200), (100..200, 600..800)] { + for y in rows { + for x in columns.clone() { + expected[y * stride + x / 8] &= !(0x80 >> (x % 8)); + } + } + } + let names: Vec> = (1..4).map(|n| format!("PCRedacted{n}").into_bytes()).collect(); + let stored = names.iter().find_map(|n| named_image(&again, 0, n)); + assert_eq!(stored, Some(expected), "{filter}"); + } +} + +/// The segments of a JBIG2 stream `encode_jbig2` wrote, each with its header: a header is the +/// segment's number (4 bytes), type, flags whose top three bits count the segments it refers to, +/// those (a byte each), its page (a byte) and its data's length (4 bytes; ISO/IEC 14492 7.2). +fn jbig2_segments(mut coded: &[u8]) -> Vec> { + let mut segments = Vec::new(); + while !coded.is_empty() { + let refers = usize::from(coded[5] >> 5); + let header = 4 + 1 + 1 + refers + 1 + 4; + let length = u32::from_be_bytes(coded[header - 4..header].try_into().unwrap()) as usize; + segments.push(coded[..header + length].to_vec()); + coded = &coded[header + length..]; + } + segments +} + +/// Two pages of a scan share one `/JBIG2Globals` stream holding the symbol dictionary, as +/// `jbig2enc -s` and most scanners write them, and the first has a mark found nowhere else (a +/// signature, say) under the area. The dictionary holds that mark's bitmap, so once the first page +/// is redacted the second page's image is stored again without the globals, and neither the +/// dictionary nor any stream naming globals stays in the saved file; both pages keep every pixel +/// outside the area. +#[test] +fn globals_a_redacted_jbig2_image_shares_with_another_page_leave_the_file() { + const LETTER: [&str; 7] = ["#####", "#....", "####.", "#....", "#....", "#....", "#####"]; + const SIGNATURE: [&str; 7] = ["#..#.##", ".##.#..", "#.#..##", "..##.#.", "##..#.#", ".#.##..", "#..#..#"]; + let (w, h) = (400usize, 40usize); + let stride = w.div_ceil(8); + let draw = |bits: &mut [u8], shape: &[&str], top: usize, left: usize| { + for (dy, row) in shape.iter().enumerate() { + for (dx, _) in row.bytes().enumerate().filter(|(_, c)| *c == b'#') { + bits[(top + dy) * stride + (left + dx) / 8] &= !(0x80 >> ((left + dx) % 8)); + } + } + }; + // The letter over and over lower down, and the signature once, top left, under the mark. + let mut first = vec![0xFFu8; stride * h]; + for top in [14usize, 24, 32] { + for x in (3..w - 6).step_by(7) { + draw(&mut first, &LETTER, top, x); + } + } + draw(&mut first, &SIGNATURE, 2, 10); + // The second page is a scan's ragged text, which Group 4 or JBIG2 stores smaller than Flate. + let second = ragged_scan(w, h); + // The first page's symbol dictionary (its second segment) goes to the globals, on no page; the + // second page's segments are numbered past it. + let mut segments = jbig2_segments(&pdfcraft_filters::encode_jbig2(&first, w as u32, h as u32).unwrap()); + assert_eq!(segments[1][4], 0, "the second segment is the symbol dictionary"); + let mut dictionary = segments.remove(1); + dictionary[6] = 0; + let page_one: Vec = segments.concat(); + let page_two: Vec = jbig2_segments(&pdfcraft_filters::encode_jbig2_generic(&second, w as u32, h as u32).unwrap()) + .into_iter() + .enumerate() + .flat_map(|(k, mut s)| { + s[..4].copy_from_slice(&(100 + k as u32).to_be_bytes()); + s + }) + .collect(); + let image = format!( + "/Type /XObject /Subtype /Image /Width {w} /Height {h} /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter /JBIG2Decode /DecodeParms << /JBIG2Globals 9 0 R >>" + ); + // Half a point a pixel, the image's top row at y 120. + let page = |content: u32, im: u32| { + format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents {content} 0 R /Resources << /XObject << /Im {im} 0 R >> >> >>") + .into_bytes() + }; + let build = |second_image: &str| { + pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >>".to_vec(), + page(5, 7), + page(6, 8), + stream("", b"q 200 0 0 20 0 100 cm /Im Do Q".as_slice()), + stream("", b"q 200 0 0 20 0 100 cm /Im Do Q".as_slice()), + stream(&image, page_one.clone()), + stream(second_image, page_two.clone()), + stream("", dictionary.clone()), + ]) + }; + let mut doc = build(&image); + // A page's picture as it is drawn, JBIG2 read with its globals. + let picture = |doc: &Document, page: usize, name: &[u8]| { + let p = pdfcraft_model::pages(doc).swap_remove(page); + let res = doc.resolve(p.dict.get(b"Resources")?).as_dict().cloned()?; + let xo = doc.resolve(res.get(b"XObject")?).as_dict().cloned()?; + match &*doc.get(xo.get(name)?.as_ref()?) { + Object::Stream(s) => decoded_image(doc, s).ok(), + _ => None, + } + }; + assert_eq!(picture(&doc, 0, b"Im"), Some(first.clone()), "the first page reads its symbols from the globals"); + assert_eq!(picture(&doc, 1, b"Im"), Some(second.clone())); + // Columns 0 to 99 of rows 0 to 12. + mark(&mut doc, 0, &[[0.25, 113.75, 49.75, 120.0]], ""); + let report = apply(&mut doc, None).unwrap(); + assert_eq!((report.images_cleared, report.images_removed, report.images_restored), (1, 0, 1)); + let mut cleared = first.clone(); + for y in 0..=12 { + for x in 0..=99 { + cleared[y * stride + x / 8] &= !(0x80 >> (x % 8)); + } + } + let saved = reopen(&doc); + assert_eq!(picture(&saved, 0, b"PCRedacted1"), Some(cleared)); + assert_eq!(picture(&saved, 1, b"Im"), Some(second.clone()), "the second page keeps its picture"); + // Stored again as a scan is: Group 4 or JBIG2 when smaller than Flate. + let restored = named_stream(&saved, 1, b"Im").unwrap(); + let flate = pdfcraft_filters::encode_flate(&second); + assert_ne!(restored.dict.name(b"Filter"), Some(&b"FlateDecode"[..])); + assert!(restored.raw.len() < flate.len(), "{:?} {} bytes, Flate {}", restored.dict.name(b"Filter"), restored.raw.len(), flate.len()); + // The dictionary's data (after its 11-byte header) codes the signature's bitmap. + let coded = &dictionary[11..]; + for (r, object) in saved.scan_objects() { + let mut bytes = Vec::new(); + pdfcraft_cos::serialize(&object, &mut bytes); + assert!(!bytes.windows(b"JBIG2Globals".len()).any(|w| w == b"JBIG2Globals"), "{r:?} still names globals"); + let data = match &*object { + Object::Stream(s) => s.decoded().unwrap_or_default(), + _ => Vec::new(), + }; + assert!(!bytes.windows(coded.len()).chain(data.windows(coded.len())).any(|w| w == coded), "the symbol dictionary stays as {r:?}"); + } + // The second page's image can't be read for certain (its JBIG2 page is shorter than the + // image), so it can't be stored without the globals: the redaction is refused. + let mut doc = build(&image.replace(&format!("/Height {h}"), &format!("/Height {}", h + 1))); + mark(&mut doc, 0, &[[0.25, 113.75, 49.75, 120.0]], ""); + assert!(matches!(apply(&mut doc, None), Err(RedactError::SharedJbig2Globals(1)))); + // Storing the second page's image again decodes it, within what is left of the run's + // allowance: with nothing left the redaction is refused as one past the allowance, not + // reported as an image that can't be read. + let mut doc = build(&image); + let first_image = [ObjRef::new(7, 0)]; + let refused = crate::restore_shared_jbig2(&mut doc, &first_image, &mut pdfcraft_cos::ImageBudget::with_work(0)); + assert!(matches!(refused, Err(RedactError::ImagesPastAllowance)), "{refused:?}"); + let restored = crate::restore_shared_jbig2(&mut doc, &first_image, &mut pdfcraft_cos::ImageBudget::with_work(u64::MAX)); + assert_eq!(restored.ok(), Some(1)); +} + /// Apply Redactions gives the page its own pruned /Resources, but the dictionary it drew through /// before (inherited from the /Pages node, or shared with another page) still named the removed /// image and form, so a full-rewrite save kept them. @@ -2190,7 +2962,8 @@ fn assert_no_secret(doc: &Document, secret: &[u8]) { pdfcraft_cos::serialize(&object, &mut bytes); assert!(!bytes.windows(secret.len()).any(|w| w == secret), "secret remains in object {r:?}"); if let Object::Stream(s) = &*object { - let data = s.decoded().unwrap(); + // Image codecs, a cleared scan coded as Group 4 or JBIG2, decode through the document. + let data = s.decoded().or_else(|_| decoded_image(&saved, s)).unwrap(); assert!(!data.windows(secret.len()).any(|w| w == secret), "secret remains in stream {r:?}"); } } @@ -3098,3 +3871,29 @@ fn a_reply_on_another_page_to_a_removed_popup_goes_too() { assert!(!bytes.windows(gone.len()).any(|w| w == gone), "{} survived", String::from_utf8_lossy(gone)); } } + +#[test] +fn a_rewritten_form_xobject_drops_the_source_text_of_added_content() { + // A form drawing invisible text whose dictionary repeats it in /PCAdded. + let form = stream( + "/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources << /Font << /F1 5 0 R >> >> /PCMark /Added /PCAdded << /Kind /Text /Text (LEAK_FORM_SOURCE) >>", + b"BT /F1 10 Tf 3 Tr 10 100 Td (HIDDEN) Tj ET BT /F1 10 Tf 10 200 Td (Shown) Tj ET", + ); + let mut doc = one_page(b"/X Do", "/XObject << /X 7 0 R >>", vec![form]); + sanitize::remove_hidden(&mut doc, &[sanitize::Hidden::HiddenText]).unwrap(); + let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); + assert!(!bytes.windows(16).any(|w| w == b"LEAK_FORM_SOURCE"), "the form's added-content source text survived"); +} + +#[test] +fn a_redacted_form_xobject_drops_the_source_text_of_added_content() { + let form = stream( + "/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources << /Font << /F1 5 0 R >> >> /PCMark /Added /PCAdded << /Kind /Text /Text (AB1234CD) >>", + b"BT /F1 10 Tf 10 100 Td (AB1234CD) Tj ET", + ); + let mut doc = one_page(b"/X Do", "/XObject << /X 7 0 R >>", vec![form]); + mark(&mut doc, 0, &[[20.0, 95.0, 40.0, 110.0]], ""); + apply(&mut doc, None).unwrap(); + let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); + assert!(!bytes.windows(4).any(|w| w == b"1234"), "redacted text left in the saved file"); +} diff --git a/crates/redact/src/tests/descriptions.rs b/crates/redact/src/tests/descriptions.rs index df6b8555dc..0495dd1f70 100644 --- a/crates/redact/src/tests/descriptions.rs +++ b/crates/redact/src/tests/descriptions.rs @@ -62,3 +62,61 @@ fn a_link_element_that_keeps_its_text_loses_the_description_of_its_removed_annot let link = doc.get(ObjRef::new(11, 0)).as_dict().cloned().unwrap(); assert_eq!(link.get(b"K"), Some(&Object::Array(vec![Object::Int(0)])), "the element keeps its text"); } + +/// [`sectioned`], with titles that repeat the secret on the paragraph that owns it, on its section +/// and on the document element above both. +fn titled() -> Document { + let mut doc = sectioned("11 0 R"); + let title = |t: &str| Object::String(pdfcraft_cos::PdfString::literal(t.as_bytes().to_vec())); + for (n, t) in [(8, "LEAK_DOC_TITLE"), (9, "LEAK_OWN_TITLE"), (11, "LEAK_SECT_TITLE"), (10, "Public title")] { + doc.update_dict(ObjRef::new(n, 0), |d| d.set(b"T".to_vec(), title(t))).unwrap(); + } + doc +} + +#[test] +fn titles_repeating_redacted_content_go_from_the_owner_and_its_ancestors() { + let mut doc = titled(); + mark(&mut doc, 0, &[[5.0, 195.0, 60.0, 212.0]], ""); + apply(&mut doc, None).unwrap(); + assert!(!full_save_holds(&doc, b"LEAK_"), "a structure title repeating the redacted word survived"); + let public = doc.get(ObjRef::new(10, 0)).as_dict().cloned().unwrap(); + assert!(public.contains(b"T"), "an element beside it keeps its own title"); +} + +#[test] +fn titles_repeating_hidden_text_go_from_the_owner_and_its_ancestors() { + use crate::sanitize::{Hidden, remove_hidden}; + let mut doc = titled(); + let hidden = + b"/P <> BDC BT /F1 10 Tf 3 Tr 10 200 Td (SECRET) Tj ET EMC /P <> BDC BT /F1 10 Tf 0 Tr 10 100 Td (Public) Tj ET EMC"; + doc.set(ObjRef::new(4, 0), Object::Stream(Stream::from_raw(Dict::new(), hidden.to_vec()))); + remove_hidden(&mut doc, &[Hidden::HiddenText]).unwrap(); + assert!(!full_save_holds(&doc, b"SECRET"), "the hidden text survived"); + assert!(!full_save_holds(&doc, b"LEAK_"), "a structure title repeating the hidden text survived"); + let public = doc.get(ObjRef::new(10, 0)).as_dict().cloned().unwrap(); + assert!(public.contains(b"T"), "an element beside it keeps its own title"); +} + +#[test] +fn an_ancestor_title_describing_a_removed_annotation_goes() { + use crate::sanitize::{Hidden, remove_hidden}; + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 8 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [6 0 R] /StructParents 0 >>" + .to_vec(), + stream("", b"/Link << /MCID 0 >> BDC BT /F1 10 Tf 10 230 Td (See the site) Tj ET EMC"), + FONT.replace("95 0 R", "12 0 R").into_bytes(), + b"<< /Type /Annot /Subtype /Link /Rect [10 228 80 240] /StructParent 1 /A << /S /URI /URI (https://example.org/) >> >>".to_vec(), + b"null".to_vec(), + b"<< /Type /StructTreeRoot /K 9 0 R /ParentTree << /Nums [0 [11 0 R] 1 11 0 R] >> /ParentTreeNextKey 2 >>".to_vec(), + b"<< /Type /StructElem /S /Document /P 8 0 R /K 10 0 R >>".to_vec(), + // Only a title above the link element repeats the address. + b"<< /Type /StructElem /S /P /P 9 0 R /T (LEAK_PARENT_TITLE) /K 11 0 R >>".to_vec(), + b"<< /Type /StructElem /S /Link /P 10 0 R /Pg 3 0 R /K [0 << /Type /OBJR /Obj 6 0 R /Pg 3 0 R >>] >>".to_vec(), + widths(), + ]); + remove_hidden(&mut doc, &[Hidden::LinksActionsScripts]).unwrap(); + assert!(!full_save_holds(&doc, b"LEAK_"), "an ancestor's title describing the removed link survived"); +} diff --git a/crates/redact/src/type3.rs b/crates/redact/src/type3.rs index f431dab902..57971571af 100644 --- a/crates/redact/src/type3.rs +++ b/crates/redact/src/type3.rs @@ -178,42 +178,38 @@ fn collect_used_resources_in_stream(data: &[u8], used: &mut UsedInGlyphs) -> Res Ok(()) } -fn prune_type3_font_resources(doc: &Document, font_res: &mut Dict, used: &UsedInGlyphs) -> bool { - let mut changed = false; - - macro_rules! prune_category { - ($cat:expr, $used_set:expr) => { - if let Some(sub_obj) = font_res.get($cat) { - let mut sub = doc.resolve(sub_obj).as_dict().cloned().unwrap_or_default(); - let names: Vec> = sub.iter().map(|(n, _)| n.to_vec()).collect(); - let mut cat_changed = false; - for n in names { - if !$used_set.contains(&n) { - sub.remove(&n); - cat_changed = true; - } - } - if cat_changed { - changed = true; - if sub.is_empty() { - font_res.remove($cat); - } else { - font_res.set($cat.to_vec(), Object::Dict(sub)); - } - } - } - }; - } - - prune_category!(b"XObject", used.xobjects); - prune_category!(b"ColorSpace", used.colorspaces); - prune_category!(b"Pattern", used.patterns); - prune_category!(b"Shading", used.shadings); - prune_category!(b"ExtGState", used.extgstates); - prune_category!(b"Font", used.fonts); - prune_category!(b"Properties", used.properties); +/// Removes the entries of `font_res`'s `category` sub-dictionary that `used` does not name, +/// dropping the sub-dictionary when nothing is left. Whether anything changed. +fn prune_category(doc: &Document, font_res: &mut Dict, category: &[u8], used: &HashSet>) -> bool { + let Some(sub_obj) = font_res.get(category) else { return false }; + let mut sub = doc.resolve(sub_obj).as_dict().cloned().unwrap_or_default(); + let unused: Vec> = sub.iter().map(|(n, _)| n.to_vec()).filter(|n| !used.contains(n)).collect(); + if unused.is_empty() { + return false; + } + for name in &unused { + sub.remove(name); + } + if sub.is_empty() { + font_res.remove(category); + } else { + font_res.set(category.to_vec(), Object::Dict(sub)); + } + true +} - changed +fn prune_type3_font_resources(doc: &Document, font_res: &mut Dict, used: &UsedInGlyphs) -> bool { + let categories: [(&[u8], &HashSet>); 7] = [ + (b"XObject", &used.xobjects), + (b"ColorSpace", &used.colorspaces), + (b"Pattern", &used.patterns), + (b"Shading", &used.shadings), + (b"ExtGState", &used.extgstates), + (b"Font", &used.fonts), + (b"Properties", &used.properties), + ]; + // Every category is pruned, even after an earlier one changed. + categories.into_iter().fold(false, |changed, (category, names)| prune_category(doc, font_res, category, names) | changed) } struct ScanContext<'a> { diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml index 28202a38b4..71467dfeb2 100644 --- a/crates/render/Cargo.toml +++ b/crates/render/Cargo.toml @@ -13,6 +13,8 @@ pdfcraft-cos = { workspace = true } pdfcraft-fonts = { workspace = true } hayro = { workspace = true } hayro-syntax = { workspace = true } +# Picks the face of an installed font collection (the version pdfcraft-fonts already uses). +skrifa = "0.44" lopdf = { workspace = true } kurbo = { workspace = true } thiserror = { workspace = true } @@ -27,7 +29,5 @@ bytemuck = { version = "1" } getrandom = { version = "0.4", features = ["wasm_js"] } [dev-dependencies] -# Checks the Japanese substitute's character coverage; the version pdfcraft-fonts already uses. -skrifa = "0.44" # Builds the decompression-bomb streams in the raster tests. flate2 = { workspace = true } diff --git a/crates/render/src/inspect.rs b/crates/render/src/inspect.rs index 71b672e547..0cf287b3f1 100644 --- a/crates/render/src/inspect.rs +++ b/crates/render/src/inspect.rs @@ -23,60 +23,27 @@ use pdfcraft_cos::page_labels::{ use crate::OpenError; use crate::structure::{LazyStructure, Structure}; -/// lopdf decodes object and cross-reference streams while it loads, with no limit unless one is -/// set: a few hundred bytes of nested FlateDecode then inflate to gigabytes. Real object and -/// xref streams are far below this. -const LOAD_STREAM_LIMIT: usize = 256 << 20; +mod annotations; +mod core; +mod model; +mod navigation; +mod resources; -/// At most this many `/State` entries of a set-layer-visibility action are read. -const MAX_LAYER_STATE: usize = 1024; - -/// Decoded bytes read from an XFA form's configuration packet (or a whole-XDP `/XFA` stream) to -/// find `dynamicRender`. Designer's packets are a few kilobytes; a whole XDP can hold the template. -const MAX_XFA_CONFIG_BYTES: usize = 16 << 20; - -/// File specifications retained from a remote-Go-To action are deliberately small. -const MAX_REMOTE_FILE_SPEC_BYTES: usize = 4096; -const MAX_REMOTE_DESTINATION_NAME_BYTES: usize = 1024; - -/// Text and geometry `DocInfo::annotations` keeps, as counted by [`retained`]. Past it the rest -/// are not listed: real comments stay far below, many small ones sharing one long string do not. -const MAX_ANNOTATION_BYTES: usize = 64 << 20; - -/// Web-address text the link listing, and name, description and embedded-file key text the -/// attachment listing, keep together. Many links or attachments can share one long string, which -/// each would otherwise copy. Past it, a link to a web address lists as a URI action without its -/// address, an attachment under its generic name without a description, and an embedded file whose -/// key does not fit is not listed. -const MAX_LINK_ATTACHMENT_TEXT_BYTES: usize = 16 << 20; - -/// The bytes a listed annotation keeps. -fn retained(a: &Annotation) -> usize { - let text = [&a.author, &a.contents, &a.modified, &a.name, &a.in_reply_to, &a.state, &a.intent]; - text.into_iter() - .map(|t| t.as_ref().map_or(0, String::len)) - .chain([ - a.subtype.len(), - std::mem::size_of_val(a.quads.as_slice()), - std::mem::size_of_val(a.points.as_slice()), - std::mem::size_of::(), - ]) - .fold(0, usize::saturating_add) -} - -/// At most this many layers are read from all of `/RBGroups` together. -const MAX_LAYER_GROUP_ENTRIES: usize = 4096; - -/// `DocInfo::outline` holds the first this many bookmarks in outline order. Opening a document -/// reads only this window; the rest are listed by page through `pdfcraft_organize`. -const OUTLINE_WINDOW: usize = 10_000; - -/// Deepest level `DocInfo::outline` nests (the top level is 0); deeper bookmarks are left out. -const OUTLINE_DEPTH: usize = 32; - -fn load_options(password: Option<&str>) -> LoadOptions { - LoadOptions { password: password.map(str::to_owned), max_decompressed_size: Some(LOAD_STREAM_LIMIT), ..LoadOptions::default() } -} +#[cfg(test)] +use annotations::{MAX_ANNOTATION_BYTES, MAX_LINK_ATTACHMENT_TEXT_BYTES, MAX_REMOTE_FILE_SPEC_BYTES, retained}; +use core::Inspector; +pub use core::{check_pages, inspect, pretty_date}; +#[cfg(test)] +use core::{inspect_structure, spend}; +pub use model::{ + Annotation, Attachment, AttachmentSource, DestView, Field, FieldKind, FontInfo, Layer, LayerOp, Link, LinkTarget, OutlineItem, PageInfo, + PageTransition, RemoteDestination, TransitionStyle, Xfa, link_replies, thread_tops, +}; +#[cfg(test)] +use navigation::{OUTLINE_DEPTH, OUTLINE_WINDOW}; +pub use resources::attachment_data; +#[cfg(test)] +use resources::{xfa_config_dynamic_render, xfa_packet_text}; #[derive(Clone, Debug, Default)] pub struct DocInfo { @@ -118,4252 +85,5 @@ pub struct DocInfo { pub warnings: Vec, } -/// What kind of XFA form a document has (`/AcroForm /XFA`). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Xfa { - /// The pages and fields are ordinary PDF; the XFA packets describe the same form (and hold - /// its data, which Acrobat shows in preference to the fields). - Static, - /// The form is laid out from the XFA packets at open (`/NeedsRendering`, or no fields and a - /// configuration that doesn't forbid dynamic rendering): the PDF pages are only a placeholder. - Dynamic, -} - -/// A page's presentation transition and display time (ISO 32000-2 §12.4.4): how the page comes -/// into view in a full-screen presentation, and how long it stays before advancing. Only pages -/// carrying `/Trans` or `/Dur` are listed. Values outside the spec's ranges fall back to the -/// defaults rather than being refused. -#[derive(Clone, Debug, PartialEq)] -pub struct PageTransition { - pub page: usize, - /// `/Trans /S`; `None` when the page has only `/Dur`. - pub style: Option, - /// `/D`: how long the transition takes, in seconds (default 1). - pub duration: f32, - /// `/Dm /H` (true) or `/V` (Split, Blinds). - pub horizontal: bool, - /// `/M /I` (true) or `/O` (Split, Box, Fly). - pub inward: bool, - /// `/Di`: 0, 90, 180, 270 or 315 degrees (Wipe, Glitter, Fly, Cover, Uncover, Push); `None` - /// for `/None` (Fly only). - pub direction: Option, - /// `/SS`: the Fly scale (default 1). - pub scale: f32, - /// `/B`: Fly with an opaque backdrop. - pub opaque: bool, - /// `/Dur`: seconds the page shows before the presentation advances; `None` waits for the - /// presenter. - pub display: Option, -} - -/// The transition styles of ISO 32000-2 table 164 (`/Trans /S`). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum TransitionStyle { - Split, - Blinds, - Box, - Wipe, - Dissolve, - Glitter, - /// `/R`: the next page replaces the old one with no effect (the default). - Replace, - Fly, - Push, - Cover, - Uncover, - Fade, -} - -impl TransitionStyle { - /// The style a `/S` name means; an unknown name is `Replace`, the spec's default. - pub fn from_name(name: &str) -> Self { - match name { - "Split" => Self::Split, - "Blinds" => Self::Blinds, - "Box" => Self::Box, - "Wipe" => Self::Wipe, - "Dissolve" => Self::Dissolve, - "Glitter" => Self::Glitter, - "Fly" => Self::Fly, - "Push" => Self::Push, - "Cover" => Self::Cover, - "Uncover" => Self::Uncover, - "Fade" => Self::Fade, - _ => Self::Replace, - } - } - - /// The name as written in a PDF (`/S`). - pub fn name(self) -> &'static str { - match self { - Self::Split => "Split", - Self::Blinds => "Blinds", - Self::Box => "Box", - Self::Wipe => "Wipe", - Self::Dissolve => "Dissolve", - Self::Glitter => "Glitter", - Self::Replace => "R", - Self::Fly => "Fly", - Self::Push => "Push", - Self::Cover => "Cover", - Self::Uncover => "Uncover", - Self::Fade => "Fade", - } - } -} - -#[derive(Clone, Debug)] -pub struct PageInfo { - /// Displayed size in points, after `/Rotate` and `/UserUnit`. - pub width: f32, - pub height: f32, - /// Page label (`/PageLabels`), falling back to the 1-based page number. - pub label: String, - /// Effective crop box in user space [x0, y0, x1, y1] (the visible region). - pub crop: [f32; 4], - /// Clockwise page rotation in degrees (0, 90, 180, 270). - pub rotation: u16, -} - -impl PageInfo { - /// A point in view space (points, y down, after `/Rotate`; what the text layer uses) → - /// PDF user space. - pub fn view_to_user(&self, x: f32, y: f32) -> [f32; 2] { - let [cx0, cy0, cx1, cy1] = self.crop; - let (a, b) = (x / self.width.max(1e-3), y / self.height.max(1e-3)); - let (u, v) = match self.rotation { - 90 => (b, 1.0 - a), - 180 => (1.0 - a, 1.0 - b), - 270 => (1.0 - b, a), - _ => (a, b), - }; - [cx0 + u * (cx1 - cx0), cy1 - v * (cy1 - cy0)] - } - - /// PDF user space → view space (the inverse of [`Self::view_to_user`]). - pub fn user_to_view(&self, x: f32, y: f32) -> [f32; 2] { - let [cx0, cy0, cx1, cy1] = self.crop; - let (u, v) = ((x - cx0) / (cx1 - cx0).max(1e-3), (cy1 - y) / (cy1 - cy0).max(1e-3)); - let (a, b) = match self.rotation { - 90 => (1.0 - v, u), - 180 => (1.0 - u, 1.0 - v), - 270 => (v, 1.0 - u), - _ => (u, v), - }; - [a * self.width, b * self.height] - } - - /// A point as displayed (points from the page's top-left, y down, before any view rotation) - /// → the `/XYZ` position `[left, top]` in user space that puts it at the window's top-left. - /// Only a coordinate that is given is pinned: a bookmark made at the top of the window - /// leaves the horizontal position alone. On a page turned a quarter, the displayed `y` decides - /// the user `left` and the displayed `x` the user `top` (the inverse of [`Self::dest_fraction`]). - /// A value that is not finite counts as not given. - pub fn xyz_for_view(&self, x: Option, y: Option) -> [Option; 2] { - let (x, y) = (x.filter(|v| v.is_finite()), y.filter(|v| v.is_finite())); - let [ux, uy] = self.view_to_user(x.unwrap_or(0.0), y.unwrap_or(0.0)); - if self.rotation.is_multiple_of(180) { [x.map(|_| ux), y.map(|_| uy)] } else { [y.map(|_| ux), x.map(|_| uy)] } - } - - /// A destination's user-space point (each coordinate possibly unspecified) → the same point - /// as a fraction of the displayed page `[x, y]` (0..1 from its top-left corner, y down), - /// after `/Rotate` and a further clockwise `view_rotation` (0, 90, 180 or 270). On a page - /// turned a quarter, the user `x` decides the displayed `y` and vice versa, so each output - /// is `None` when the input it comes from is (or is not finite). Points off the page are - /// clamped to its edge. - pub fn dest_fraction(&self, x: Option, y: Option, view_rotation: u16) -> [Option; 2] { - let [cx0, cy0, cx1, cy1] = self.crop; - let frac = |v: f32| if v.is_finite() { Some(v.clamp(0.0, 1.0)) } else { None }; - // Unrotated fractions across (u, from the crop's left) and down (v, from its top). - let u = x.and_then(|x| frac((x - cx0) / (cx1 - cx0).max(1e-3))); - let v = y.and_then(|y| frac((cy1 - y) / (cy1 - cy0).max(1e-3))); - let flip = |f: Option| f.map(|f| 1.0 - f); - match (u32::from(self.rotation) + u32::from(view_rotation)) % 360 { - 90 => [flip(v), u], - 180 => [flip(u), flip(v)], - 270 => [v, flip(u)], - _ => [u, v], - } - } - - /// A view-space rectangle → the normalized user-space rectangle covering the same area of the - /// page (`[x0, y0, x1, y1]`, `x0 <= x1`, `y0 <= y1`). Under `/Rotate` the corners swap roles, - /// so a view rectangle's top-left is not in general the user rectangle's `[x0, y1]`. - pub fn view_rect_to_user(&self, r: [f32; 4]) -> [f32; 4] { - let (a, b) = (self.view_to_user(r[0], r[1]), self.view_to_user(r[2], r[3])); - [a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])] - } - - /// A view-space rectangle as a text-markup quad in user space: top-left, top-right, - /// bottom-left, bottom-right as read on screen. - pub fn view_rect_to_quad(&self, r: [f32; 4]) -> [f64; 8] { - let mut q = [0.0; 8]; - for (i, (x, y)) in [(r[0], r[1]), (r[2], r[1]), (r[0], r[3]), (r[2], r[3])].into_iter().enumerate() { - let [ux, uy] = self.view_to_user(x, y); - q[2 * i] = ux as f64; - q[2 * i + 1] = uy as f64; - } - q - } -} - -/// A clickable link annotation. -#[derive(Clone, Debug)] -pub struct Link { - pub page: usize, - /// Zero-based position in the page's raw `/Annots` array. - pub index: usize, - pub rect: [f32; 4], - pub target: LinkTarget, -} - -#[derive(Clone, Debug, PartialEq)] -pub enum LinkTarget { - /// A destination in this document: the 0-based page and where on it the view goes. - Page(usize, DestView), - Uri(String), - /// A set-layer-visibility action (`SetOCGState`, ISO 32000-2 §12.6.4.13): each change in - /// order, naming the layer by its optional content group. With `preserve_rb`, a layer turned - /// on turns off the other layers of its radio-button groups. - SetLayers { - changes: Vec<(LayerOp, (u32, u16))>, - preserve_rb: bool, - }, - /// A local-file remote Go-To action. The caller resolves `file` relative to the source path. - /// `launch` marks a Launch action that names a PDF file (see `launch_pdf`), opened the same - /// way: as a document in PdfCraft, never by the operating system. - RemoteGoTo { - launch: bool, - file: String, - destination: RemoteDestination, - new_window: Option, - }, - Other(String), -} - -#[derive(Clone, Debug, PartialEq)] -pub enum RemoteDestination { - Default, - Page { page: usize, view: DestView }, - Named(String), -} - -/// What a set-layer-visibility action does to a layer. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum LayerOp { - On, - Off, - Toggle, -} - -/// Where an explicit destination (ISO 32000-2 §12.3.2.2, Table 149) places its page in the -/// window. Coordinates are PDF user space; `None` is a `null` (or unusable) operand, which keeps -/// that part of the view as it is. -#[derive(Clone, Copy, Debug, Default, PartialEq)] -pub enum DestView { - /// The top of the page at the current zoom: a destination with no position, or one whose - /// type is missing or unknown. - #[default] - Top, - /// `/XYZ left top zoom`: the point (left, top) at the window's upper-left corner, at `zoom` - /// (1.0 = 100%; 0 or null keeps the current zoom). - Xyz { left: Option, top: Option, zoom: Option }, - /// `/Fit`, and `/FitB` (fit the page's content box; read as the whole page). - Fit, - /// `/FitH top`, and `/FitBH`: fit the width, with `top` at the window's top edge. - FitH { top: Option }, - /// `/FitV left`, and `/FitBV`: fit the height, with `left` at the window's left edge. - FitV { left: Option }, - /// `/FitR left bottom right top`: fit the rectangle `[x0, y0, x1, y1]` (normalized). - FitR { rect: [f32; 4] }, -} - -impl DestView { - /// The view an explicit destination array asks for, from its elements after the page - /// (`/XYZ left top zoom`, `/FitH top`, …). Hostile input never fails: a missing or unknown - /// type, or a `/FitR` without four usable numbers, is [`DestView::Top`]; a non-numeric, - /// non-finite or missing operand is `None`; a zoom that is not positive is `None`. - pub fn from_operands(kind: Option<&[u8]>, operands: &[Option]) -> Self { - let n = |i: usize| operands.get(i).copied().flatten().filter(|v| v.is_finite()); - match kind { - Some(b"XYZ") => Self::Xyz { left: n(0), top: n(1), zoom: n(2).filter(|z| *z > 0.0) }, - Some(b"Fit" | b"FitB") => Self::Fit, - Some(b"FitH" | b"FitBH") => Self::FitH { top: n(0) }, - Some(b"FitV" | b"FitBV") => Self::FitV { left: n(0) }, - Some(b"FitR") => match (n(0), n(1), n(2), n(3)) { - (Some(x0), Some(y0), Some(x1), Some(y1)) => Self::FitR { rect: [x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)] }, - _ => Self::Top, - }, - _ => Self::Top, - } - } -} - -#[derive(Clone, Debug)] -pub struct OutlineItem { - pub title: String, - pub page: Option, - /// Where on `page` the bookmark goes ([`DestView::Top`] for a bookmark without a page). - pub view: DestView, - pub children: Vec, - /// Whether the bookmark starts open: only a positive `/Count` says so (ISO 32000-2 §12.3.3, - /// Table 151). A negative one is closed, and so is a missing one, which a bookmark with - /// children should not have. - pub open: bool, - /// The title's colour (`/C`, DeviceRGB, ISO 32000-2 §12.3.3 Table 151), or `None` for the - /// default black or a value that isn't three numbers. - pub color: Option<[f32; 3]>, - /// The title's style (`/F`, Table 152): bit 1 italic, bit 2 bold. - pub italic: bool, - pub bold: bool, -} - -#[derive(Clone, Debug)] -pub struct Annotation { - pub page: usize, - pub subtype: String, - pub author: Option, - pub contents: Option, - pub modified: Option, - /// `/NM` of this annotation, and of the one it replies to (`/IRT`). - pub name: Option, - pub in_reply_to: Option, - /// The page and `/Annots` position of the comment `/IRT` points at, found by object - /// reference. Threads follow this rather than `/NM`, which need not be unique: PyMuPDF - /// names the first comment on every page `fitz-A0`. `None` when `/IRT` leads to no listed - /// comment ([`link_replies`]). - pub parent: Option<(usize, usize)>, - /// `/RT /Group`: grouped with the annotation `/IRT` points at (Replace Text's caret with its - /// strikeout) rather than a reply to it. - pub grouped: bool, - /// Rect in PDF user space: [x0, y0, x1, y1]. - pub rect: [f32; 4], - pub color: Option<[f32; 3]>, - /// Position in the page's `/Annots` (how edits address the comment). - pub index: usize, - /// For a status reply (`/State`, Acrobat's "Set status"): the state it sets. - pub state: Option, - /// Text markup quadrilaterals (`/QuadPoints`), 8 numbers each. - pub quads: Vec<[f32; 8]>, - /// The Locked flag (`/F` bit 8): the comment can't be moved, resized, restyled or deleted. - pub locked: bool, - /// `/IT`, the intent: `FreeTextCallout`, `PolygonCloud`, `FreeTextTypeWriter`… - pub intent: Option, - /// A Fill & Sign mark: typed text, a check, cross, dot or line, or a typed, drawn or picture - /// signature or initials (the rules of `pdfcraft_annot::is_fill_sign`). - pub fill_sign: bool, - /// The NoRotate flag (`/F` bit 5), set on sticky notes and attachments: on a turned page the - /// appearance stays upright, turned about the upper-left corner of `rect` - /// ([`Self::shown_rect`]). - pub no_rotate: bool, - /// The points that shape a line (its two ends, `/L`) or connected lines, a polygon or a - /// cloud (their corners, `/Vertices`), in user space; empty for other comments. - pub points: Vec<[f32; 2]>, -} - -impl Annotation { - /// A checkmark reply (`/StateModel /Marked`): "Marked" or "Unmarked" rather than a review status. - pub fn is_mark(&self) -> bool { - matches!(self.state.as_deref(), Some("Marked" | "Unmarked")) - } - - /// A reply (or status reply) to another comment rather than a comment of its own. - pub fn is_reply(&self) -> bool { - self.parent.is_some() - } - - /// A comment of its own: not a reply, and not a status or checkmark. One whose comment can't - /// be found (deleted from another page, or never listed) has nothing to show, so it is listed - /// nowhere: not as a card, on the page or in a count. - pub fn is_comment(&self) -> bool { - !self.is_reply() && self.state.is_none() - } - - /// Whether this annotation's `/IRT` points at `other`. - pub fn replies_to(&self, other: &Annotation) -> bool { - self.parent == Some((other.page, other.index)) - } - - /// Where the comment is drawn on a page turned by `rotation` (`PageInfo::rotation`), in user - /// space: `rect`, or for a NoRotate comment `rect` turned upright about its upper-left corner - /// (see [`Self::no_rotate`]). Hit-testing, selection and anything placed beside the comment - /// use this, so they match the drawing. - pub fn shown_rect(&self, rotation: u16) -> [f32; 4] { - self.shown(self.rect.map(f64::from), rotation).map(|v| v as f32) - } - - /// [`Self::shown_rect`] for this comment with its `/Rect` at `rect` (one being moved or - /// resized). - pub fn shown(&self, rect: [f64; 4], rotation: u16) -> [f64; 4] { - if self.no_rotate { upright_rect(rect, rotation) } else { rect } - } - - /// The `/Rect` that shows this comment in the user-space box `shown`: the inverse of - /// [`Self::shown`], for a box resized as drawn. - pub fn rect_for_shown(&self, shown: [f64; 4], rotation: u16) -> [f64; 4] { - if self.no_rotate { rect_from_upright(shown, rotation) } else { shown } - } -} - -/// The user-space box a NoRotate annotation with `/Rect` `rect` is drawn in on a page turned -/// clockwise by `rotation` (ISO 32000-2 §12.5.3): `rect` turned as far counterclockwise about its -/// upper-left corner `(x0, y1)`, which stays where it is on the page. As displayed, the box's -/// top-left is that corner, the rectangle's width runs across and its height down, as on an -/// unturned page. `rect` itself for 0 or anything that isn't a quarter turn. The vendored -/// renderer draws the appearance there (hayro-interpret patch 37). -fn upright_rect(rect: [f64; 4], rotation: u16) -> [f64; 4] { - let [x0, y0, x1, y1] = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])]; - let (w, h) = (x1 - x0, y1 - y0); - match rotation { - 90 => [x0, y1, x0 + h, y1 + w], - 180 => [x0 - w, y1, x0, y1 + h], - 270 => [x0 - h, y1 - w, x0, y1], - _ => [x0, y0, x1, y1], - } -} - -/// The `/Rect` whose [`upright_rect`] is `shown`: `shown` turned back about the corner that -/// stays put. -fn rect_from_upright(shown: [f64; 4], rotation: u16) -> [f64; 4] { - let [s0, s1, s2, s3] = [shown[0].min(shown[2]), shown[1].min(shown[3]), shown[0].max(shown[2]), shown[1].max(shown[3])]; - let (sw, sh) = (s2 - s0, s3 - s1); - // The upper-left corner of `/Rect` and the rectangle's width and height. - let ((x0, y1), w, h) = match rotation { - 90 => ((s0, s1), sh, sw), - 180 => ((s2, s1), sw, sh), - 270 => ((s2, s3), sh, sw), - _ => return [s0, s1, s2, s3], - }; - [x0, y1 - h, x0 + w, y1] -} - -/// Settle the reply links of a document's annotations: a reply keeps its `parent` only when -/// following the links reaches a comment of its own within 64 steps. An `/IRT` to an unlisted -/// object, a pop-up, the annotation itself or a loop leaves it a comment of its own, so it stays -/// in view instead of hanging off nothing. Both comment readers (inspect, and the engine's list -/// after an edit) run this. -/// -/// Only comments can be parents: both readers leave links, form widgets, pop-ups and other -/// non-markup annotations out of `annotations` (`pdfcraft_annot::is_comment_subtype`), so an -/// `/IRT` to one of those finds no parent here. Pop-ups are skipped again in case a caller's list -/// has them. -pub fn link_replies(annotations: &mut [Annotation]) { - let at: HashMap<(usize, usize), usize> = - annotations.iter().enumerate().filter(|(_, a)| a.subtype != "Popup").map(|(i, a)| ((a.page, a.index), i)).collect(); - let links: Vec> = - annotations.iter().enumerate().map(|(i, a)| a.parent.and_then(|p| at.get(&p).copied()).filter(|&j| j != i)).collect(); - for (i, a) in annotations.iter_mut().enumerate() { - let mut up = links.get(i).copied().flatten(); - let mut settled = false; - for _ in 0..64 { - let Some(j) = up else { break }; - up = links.get(j).copied().flatten(); - if up.is_none() { - settled = true; - break; - } - } - if !settled { - a.parent = None; - } - } -} - -/// The comment each reply's thread starts from, by the reply's page and `/Annots` position: a -/// reply to a reply files under that comment too. Expects links [`link_replies`] settled. -pub fn thread_tops(annotations: &[Annotation]) -> HashMap<(usize, usize), (usize, usize)> { - let parents: HashMap<(usize, usize), (usize, usize)> = annotations.iter().filter_map(|a| Some(((a.page, a.index), a.parent?))).collect(); - parents - .iter() - .map(|(&reply, &first)| { - let mut top = first; - for _ in 0..64 { - let Some(&up) = parents.get(&top) else { break }; - top = up; - } - (reply, top) - }) - .collect() -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum FieldKind { - Text, - CheckBox, - Radio, - PushButton, - Combo, - List, - Signature, - Unknown, -} - -#[derive(Clone, Debug)] -pub struct Field { - pub name: String, - pub kind: FieldKind, - pub value: Option, - pub page: Option, - pub tooltip: Option, - pub has_actions: bool, - /// Rect of the first widget, in user space. - pub rect: Option<[f32; 4]>, -} - -#[derive(Clone, Debug)] -pub struct Layer { - /// Object number and generation of the optional content group. - pub id: (u32, u16), - pub name: String, - pub visible: bool, - /// Shown as the file opens: the default configuration (`/OCProperties /D`). - pub initial: bool, -} - -#[derive(Clone, Debug)] -pub struct Attachment { - pub name: String, - pub description: Option, - pub size: Option, - pub source: AttachmentSource, -} - -/// Where an attachment lives (so its bytes can be fetched on demand). -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum AttachmentSource { - /// Document-level: `/Names /EmbeddedFiles` entry with this key. - Document { key: String }, - /// A FileAttachment annotation: page index and position in the page's `/Annots`. - Annotation { page: usize, index: usize }, -} - -/// A font used by the document (Document Properties ▸ Fonts). -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct FontInfo { - /// BaseFont without the subset tag. - pub name: String, - /// Type1, TrueType, Type0, Type3, MMType1, CIDFontType0/2 … - pub kind: String, - pub embedded: bool, - pub subset: bool, - pub encoding: Option, - /// The face standing in for a program the document does not embed. `None` when the font is - /// embedded (nothing stands in) or when no face was available at all -- `substitution` then - /// says which of the two it is. - pub substitute: Option, - /// Why a face was substituted, as a sentence for whoever has to act on it. `None` only when - /// the document's own glyphs are being shown. - pub substitution: Option, -} - -/// Check that `bytes` still open as the same document: the parser reads them and the page -/// count matches. Content-only edits skip the full [`inspect`] (seconds on very large files) -/// but must still catch an edit that produced something the viewer cannot display, so the -/// caller can roll it back. Never panics: parser crashes are caught and reported. -pub fn check_pages(bytes: Arc>, password: Option<&str>, expected: usize) -> Result<(), OpenError> { - let pdf = catch_unwind(AssertUnwindSafe(|| Pdf::new_with_password(bytes, password.unwrap_or("")))) - .map_err(|p| OpenError::Invalid(format!("the parser crashed: {}", crate::raster::panic_message(&p))))? - .map_err(|e| match e { - LoadPdfError::Decryption(DecryptionError::PasswordProtected) => { - if password.is_some() { - OpenError::WrongPassword - } else { - OpenError::NeedsPassword - } - } - LoadPdfError::Decryption(other) => OpenError::Unsupported(format!("encryption not supported: {other:?}")), - LoadPdfError::Invalid => OpenError::Invalid("no readable page tree or cross-reference data was found".into()), - })?; - let pages = catch_unwind(AssertUnwindSafe(|| pdf.pages().len())) - .map_err(|p| OpenError::Invalid(format!("the page tree could not be read: {}", crate::raster::panic_message(&p))))?; - if pages != expected { - return Err(OpenError::Invalid(format!("the edit changed the page count from {expected} to {pages}"))); - } - Ok(()) -} - -/// Inspect a document. Fails only if the renderer itself cannot open the file (or needs a -/// password). Never panics: parser crashes are caught and reported. -pub fn inspect(bytes: Arc>, password: Option<&str>) -> Result { - let pdf = catch_unwind(AssertUnwindSafe(|| Pdf::new_with_password(bytes.clone(), password.unwrap_or("")))) - .map_err(|p| OpenError::Invalid(format!("the parser crashed: {}", crate::raster::panic_message(&p))))? - .map_err(|e| match e { - LoadPdfError::Decryption(DecryptionError::PasswordProtected) => { - if password.is_some() { - OpenError::WrongPassword - } else { - OpenError::NeedsPassword - } - } - LoadPdfError::Decryption(other) => OpenError::Unsupported(format!("encryption not supported: {other:?}")), - LoadPdfError::Invalid => OpenError::Invalid("no readable page tree or cross-reference data was found".into()), - })?; - let mut info = DocInfo { file_size: bytes.len(), pdf_version: version_label(&format!("{:?}", pdf.version())), ..Default::default() }; - let pages = catch_unwind(AssertUnwindSafe(|| { - let mut out = Vec::new(); - for (i, page) in pdf.pages().iter().enumerate() { - let (w, h) = page.render_dimensions(); - let c = page.intersected_crop_box(); - let rotation = match page.rotation() { - hayro::hayro_syntax::page::Rotation::None => 0, - hayro::hayro_syntax::page::Rotation::Horizontal => 90, - hayro::hayro_syntax::page::Rotation::Flipped => 180, - hayro::hayro_syntax::page::Rotation::FlippedHorizontal => 270, - }; - let crop = [c.x0 as f32, c.y0 as f32, c.x1 as f32, c.y1 as f32]; - // Degenerate boxes still get a usable placeholder size so layout never divides by zero. - let (w, h) = if w.is_finite() && h.is_finite() && w >= 1.0 && h >= 1.0 { (w, h) } else { (612.0, 792.0) }; - out.push(PageInfo { width: w, height: h, label: (i + 1).to_string(), crop, rotation }); - } - out - })); - match pages { - Ok(p) => info.pages = p, - Err(p) => return Err(OpenError::Invalid(format!("the page tree could not be read: {}", crate::raster::panic_message(&p)))), - } - let options = load_options(password); - // Geometry no longer needs the renderer parser. Do not overlap its allocations with - // structural inspection, including the compatibility fallback for damaged files. - drop(pdf); - inspect_structure(&mut info, |tmp| { - // The lazy reader first; lopdf's repair/tolerance path when it can't expose the same - // page tree or an object a panel needs. No partial result is published. - let lazy = catch_unwind(AssertUnwindSafe(|| { - let doc = LazyStructure::new(bytes.clone(), password).ok()?; - let mut t = DocInfo { pages: tmp.pages.clone(), ..Default::default() }; - let inspector = Inspector::new(&doc); - if inspector.page_index.len() != t.pages.len() { - return None; - } - inspector.fill(&mut t); - (!doc.failed()).then_some(t) - })) - .ok() - .flatten(); - if let Some(t) = lazy { - *tmp = t; - return Ok(()); - } - let doc = Document::load_mem_with_options(&bytes, options).map_err(|e| e.to_string())?; - Inspector::new(&doc).fill(tmp); - Ok(()) - }); - if info.pages.is_empty() { - return Err(OpenError::Invalid("the document has no pages".into())); - } - Ok(info) -} - -// Commit auxiliary metadata only after inspection succeeds; renderer geometry is the fallback. -fn inspect_structure(info: &mut DocInfo, fill: impl FnOnce(&mut DocInfo) -> Result<(), String>) { - let structure = catch_unwind(AssertUnwindSafe(|| { - let mut tmp = DocInfo { pages: info.pages.clone(), ..Default::default() }; - fill(&mut tmp)?; - Ok::<_, String>(tmp) - })); - match structure { - Ok(Ok(mut filled)) => { - filled.file_size = info.file_size; - filled.pdf_version = std::mem::take(&mut info.pdf_version); - *info = filled; - } - Ok(Err(e)) => info.warnings.push(format!("Some document structure (bookmarks, comments, fields) could not be read: {e}")), - Err(p) => { - info.warnings.push(format!("Document structure inspection crashed and was skipped: {}", crate::raster::panic_message(&p))); - } - } -} - -struct Inspector<'a> { - doc: &'a dyn Structure, - page_ids: Vec, - page_index: HashMap, - /// Named destinations (`/Names /Dests` tree), keyed by raw string bytes, built once. - /// Looking each name up by walking the tree was O(links × names): minutes on manuals. - named: HashMap, &'a Object>, - /// The page of each annotation (the first page that lists it), built when a field's widget - /// has no `/P`. Searching every page's `/Annots` for each such field was O(fields × - /// annotations): a second on a form of 8,000 fields. - annotation_pages: OnceCell>, -} - -impl<'a> Inspector<'a> { - fn new(doc: &'a dyn Structure) -> Self { - let page_ids = doc.page_ids(); - let page_index = page_ids.iter().copied().enumerate().map(|(index, id)| (id, index)).collect(); - let mut me = Self { doc, page_ids, page_index, named: HashMap::new(), annotation_pages: OnceCell::new() }; - let mut named = HashMap::new(); - if let Some(tree) = - doc.catalog().ok().and_then(|c| c.get(b"Names").ok()).and_then(|o| me.dict(o)).and_then(|n| n.get(b"Dests").ok()).and_then(|o| me.dict(o)) - { - me.name_tree_raw(tree, &mut HashSet::new(), 0, &mut named); - } - me.named = named; - me - } - - fn fill(&self, info: &mut DocInfo) { - // lopdf drops /Encrypt from the trailer once it has decrypted the file. - info.encrypted = self.doc.encrypted(); - if let Some(d) = self.doc.trailer().get(b"Info").ok().and_then(|o| self.dict(o)) { - info.title = self.text(d, b"Title"); - info.author = self.text(d, b"Author"); - info.subject = self.text(d, b"Subject"); - info.keywords = self.text(d, b"Keywords"); - info.creator = self.text(d, b"Creator"); - info.producer = self.text(d, b"Producer"); - const STANDARD: [&[u8]; 9] = - [b"Title", b"Author", b"Subject", b"Keywords", b"Creator", b"Producer", b"CreationDate", b"ModDate", b"Trapped"]; - // The same rule as the engine's list after an edit (`pdfcraft_organize::custom_info`, - // trimmed and without empty values): text strings only, so a name value is not a - // custom property, and the cap counts listed entries. - info.custom = d - .iter() - .filter(|(k, _)| !STANDARD.contains(&k.as_slice())) - .filter_map(|(k, v)| match self.resolve(v) { - Object::String(bytes, _) => { - let v = text_string(bytes).trim_matches(|c: char| c == '\0' || c.is_whitespace()).to_string(); - (!v.is_empty()).then(|| (String::from_utf8_lossy(k).into_owned(), v)) - } - _ => None, - }) - .take(256) - .collect(); - } - let Ok(catalog) = self.doc.catalog() else { return }; - info.tagged = catalog - .get(b"MarkInfo") - .ok() - .and_then(|o| self.dict(o)) - .and_then(|m| m.get(b"Marked").ok()) - // Any value may be written as an indirect reference, `/Marked 9 0 R` included, so the - // flag is resolved before it is read as a boolean (#3671). - .map(|o| self.resolve(o)) - .and_then(|o| o.as_bool().ok()) - .unwrap_or(false); - match self.page_labels(catalog, &mut info.pages) { - Ok(notes) => info.warnings.extend(notes.into_iter().map(|note| format!("Page labels: {note}."))), - Err(reason) => info.warnings.push(format!("Page labels were skipped: {reason}; using physical page numbers.")), - } - if let Some(first) = catalog.get(b"Outlines").ok().and_then(|o| self.dict(o)).and_then(|d| d.get(b"First").ok()) { - (info.outline, info.outline_more) = self.outline_window(first); - } - let mut text_budget = MAX_LINK_ATTACHMENT_TEXT_BYTES; - self.annotations(info, &mut text_budget); - self.transitions(info); - let mut field_seen = HashSet::new(); - if let Some(form) = catalog.get(b"AcroForm").ok().and_then(|o| self.dict(o)) - && let Ok(fields) = form.get(b"Fields").and_then(|o| self.resolve(o).as_array()) - { - for f in fields { - self.field(f, None, &mut info.fields, &mut field_seen, 0); - } - } - // Widgets no `/Fields` entry reaches are fields too (Acrobat and the browsers fill them); - // each is adopted through its topmost unlisted `/Parent`, so a split field stays one. - for &pid in &self.page_ids { - let Ok(page_dict) = self.doc.get_dictionary(pid) else { continue }; - let Ok(Object::Array(annots)) = page_dict.get(b"Annots").map(|o| self.resolve(o)) else { continue }; - for a in annots { - let Some(d) = self.dict(a) else { continue }; - if self.name(d, b"Subtype").as_deref() != Some("Widget") - || (d.get(b"FT").is_err() && d.get(b"T").is_err() && d.get(b"Parent").is_err()) - { - continue; - } - if let Object::Reference(id) = a - && let Some(root) = self.unlisted_field(*id, &field_seen) - { - self.field(&Object::Reference(root), None, &mut info.fields, &mut field_seen, 0); - } - } - } - info.has_javascript |= info.fields.iter().any(|f| f.has_actions); - let needs_rendering = catalog.get(b"NeedsRendering").ok().and_then(|o| self.resolve(o).as_bool().ok()).unwrap_or(false); - if let Some(xfa) = catalog.get(b"AcroForm").ok().and_then(|o| self.dict(o)).and_then(|form| form.get(b"XFA").ok()) { - // A form with no fields is taken to be laid out from its template, unless its - // configuration says it is a static form (#3180): Designer writes - // `forbidden` for one, whose pages are the form. - let static_form = self.xfa_dynamic_render(xfa).as_deref() == Some("forbidden"); - info.xfa = Some(if needs_rendering || (info.fields.is_empty() && !static_form) { Xfa::Dynamic } else { Xfa::Static }); - } - self.layers(catalog, &mut info.layers); - info.layer_groups = self.layer_groups(catalog); - self.fonts(&mut info.fonts); - if let Some(names) = catalog.get(b"Names").ok().and_then(|o| self.dict(o)) { - if let Some(ef) = names.get(b"EmbeddedFiles").ok().and_then(|o| self.dict(o)) { - let mut seen = HashSet::new(); - for (name, spec) in self.name_tree(ef, &mut seen, 0, &mut text_budget) { - // The key is kept a second time when it is also the name. - let fallback = |budget: &mut usize| spend(name.as_bytes(), budget, |_| name.clone()); - info.attachments.push(self.attachment(spec, AttachmentSource::Document { key: name.clone() }, &mut text_budget, fallback)); - } - } - info.has_javascript |= names.get(b"JavaScript").is_ok(); - } - if let Ok(action) = catalog.get(b"OpenAction") - && let Some(dict) = self.dict(action) - && dict.get(b"S").ok().and_then(|o| self.resolve(o).as_name().ok()) == Some(&b"JavaScript"[..]) - { - info.has_javascript = true; - } - info.open_dest = catalog.get(b"OpenAction").ok().and_then(|o| self.open_dest(o)); - } - - /// The destination `/OpenAction` opens at (ISO 32000-2 §7.7.2 Table 29): an explicit - /// destination array, a `/GoTo` action's `/D`, or (leniently, since Table 29 asks for an array - /// or an action) a destination name or string; a name or string is looked up as a named - /// destination (§12.3.2.4). Any other action (a script, a remote go-to) has none here, and - /// neither has a page number past the end of the document. - fn open_dest(&self, action: &Object) -> Option<(usize, DestView)> { - let dest = match self.resolve(action) { - Object::Dictionary(d) => { - if d.get(b"S").ok().and_then(|o| self.resolve(o).as_name().ok()) != Some(&b"GoTo"[..]) { - return None; - } - d.get(b"D").ok()? - } - other => other, - }; - self.dest(dest, 0).filter(|(page, _)| *page < self.page_ids.len()) - } - - // ── object helpers ────────────────────────────────────────────────────────────────────── - - /// `/Trans` and `/Dur` of every page (ISO 32000-2 §12.4.4), in page order. Lenient: a value - /// outside the spec's choices takes that entry's default, and a page with neither entry is - /// not listed. Bounded by the page count. - fn transitions(&self, info: &mut DocInfo) { - for (page, &pid) in self.page_ids.iter().enumerate() { - let Ok(d) = self.doc.get_dictionary(pid) else { continue }; - let display = d.get(b"Dur").ok().and_then(|o| self.resolve(o).as_float().ok()).filter(|s| s.is_finite() && *s >= 0.0); - let trans = d.get(b"Trans").ok().and_then(|o| self.dict(o)); - if trans.is_none() && display.is_none() { - continue; - } - let mut t = PageTransition { - page, - style: None, - duration: 1.0, - horizontal: true, - inward: true, - direction: Some(0), - scale: 1.0, - opaque: false, - display, - }; - if let Some(tr) = trans { - t.style = Some(self.name(tr, b"S").map_or(TransitionStyle::Replace, |n| TransitionStyle::from_name(&n))); - let num = |key: &[u8]| tr.get(key).ok().and_then(|o| self.resolve(o).as_float().ok()).filter(|v| v.is_finite()); - t.duration = num(b"D").filter(|d| *d >= 0.0).unwrap_or(1.0); - t.horizontal = self.name(tr, b"Dm").as_deref() != Some("V"); - t.inward = self.name(tr, b"M").as_deref() != Some("O"); - t.direction = match tr.get(b"Di").map(|o| self.resolve(o)) { - Ok(Object::Name(n)) if n.as_slice() == b"None" => None, - Ok(o) => match o.as_i64().ok() { - Some(0) => Some(0), - Some(90) => Some(90), - Some(180) => Some(180), - Some(270) => Some(270), - Some(315) => Some(315), - _ => Some(0), - }, - Err(_) => Some(0), - }; - t.scale = num(b"SS").filter(|s| *s > 0.0).unwrap_or(1.0); - t.opaque = matches!(tr.get(b"B").map(|o| self.resolve(o)), Ok(Object::Boolean(true))); - } - info.transitions.push(t); - } - } - - /// The value of `dynamicRender` in an XFA form's configuration packet (`config`, under - /// `acrobat/acrobat7`): `required` for a dynamic form, `forbidden` for a static one. `/XFA` is - /// either one stream holding the whole XDP or an array of packet names and streams (ISO - /// 32000-1 §12.7.8). Lenient: `None` when the packet is missing, unreadable or over - /// `MAX_XFA_CONFIG_BYTES` decoded, or has no such element. - fn xfa_dynamic_render(&self, xfa: &'a Object) -> Option { - let stream = match self.resolve(xfa) { - Object::Array(items) => { - let at = items.iter().position(|o| matches!(self.resolve(o), Object::String(name, _) if name.as_slice() == b"config"))?; - self.resolve(items.get(at.checked_add(1)?)?) - } - whole => whole, - }; - let stream = stream.as_stream().ok()?; - let bytes = if stream.dict.get(b"Filter").is_ok() { - stream.decompressed_content_with_limit(MAX_XFA_CONFIG_BYTES).ok()? - } else { - stream.content.get(..stream.content.len().min(MAX_XFA_CONFIG_BYTES))?.to_vec() - }; - xfa_config_dynamic_render(&xfa_packet_text(&bytes)) - } - - fn resolve(&self, o: &'a Object) -> &'a Object { - self.doc.dereference(o).map(|(_, o)| o).unwrap_or(o) - } - - /// The object a reference ends at after following any chain of references; `None` for a - /// direct object or a broken reference. - fn resolved_id(&self, o: &Object) -> Option { - self.doc.dereference(o).ok().and_then(|(id, _)| id) - } - - fn dict(&self, o: &'a Object) -> Option<&'a Dictionary> { - match self.resolve(o) { - Object::Dictionary(d) => Some(d), - Object::Stream(s) => Some(&s.dict), - _ => None, - } - } - - fn text(&self, d: &Dictionary, key: &[u8]) -> Option { - let s = trimmed_text(self.resolve(d.get(key).ok()?))?; - (!s.is_empty()).then_some(s) - } - - /// [`Self::text`], converted and spent from `budget` as [`spend`] does, so text that trims - /// short or to nothing still spends its raw length. - fn budgeted_text(&self, d: &Dictionary, key: &[u8], budget: &mut usize) -> Option { - let o = self.resolve(d.get(key).ok()?); - let (Object::String(bytes, _) | Object::Name(bytes)) = o else { return None }; - let text = spend(bytes, budget, |_| trimmed_text(o).unwrap_or_default())?; - (!text.is_empty()).then_some(text) - } - - fn name(&self, d: &Dictionary, key: &[u8]) -> Option { - self.resolve(d.get(key).ok()?).as_name().ok().map(|n| String::from_utf8_lossy(n).into_owned()) - } - - /// Whether an annotation is a Fill & Sign mark. Keep in step with - /// `pdfcraft_annot::is_fill_sign`, which the engine's comment list uses. - fn is_fill_sign(&self, d: &Dictionary, subtype: &str) -> bool { - let yes = |key: &[u8]| matches!(d.get(key).map(|o| self.resolve(o)), Ok(Object::Boolean(true))); - if yes(b"PCFillSign") { - return true; - } - match subtype { - "FreeText" => self.name(d, b"IT").as_deref() == Some("FreeTextTypeWriter"), - "Stamp" => match self.name(d, b"Name").as_deref() { - Some("PCCheck" | "PCCross" | "PCDot" | "PCLine" | "PCTypedSignature") => true, - Some("PCCustomSignature" | "PCCustomInitials") => yes(b"PCPictureImage"), - _ => false, - }, - "Ink" => self.text(d, b"Subj").as_deref() == Some("Signature"), - _ => false, - } - } - - fn page_of(&self, o: &Object) -> Option { - match o { - Object::Reference(id) => self.page_index.get(id).copied(), - // Some producers write page *numbers* in remote-style destinations. - Object::Integer(n) => usize::try_from(*n).ok(), - _ => None, - } - } - - // ── destinations ──────────────────────────────────────────────────────────────────────── - - /// The page and view of a destination: an explicit array (`[page /XYZ left top zoom]`, …), - /// a dictionary with `/D`, or a named destination that resolves to one. - fn dest(&self, dest: &Object, depth: u32) -> Option<(usize, DestView)> { - if depth > 8 { - return None; - } - match self.resolve(dest) { - Object::Array(a) => { - let page = a.first().and_then(|p| self.page_of(p))?; - let kind = a.get(1).map(|k| self.resolve(k)).and_then(|k| k.as_name().ok()); - let operands: Vec> = a.iter().skip(2).take(4).map(|o| self.resolve(o).as_float().ok()).collect(); - Some((page, DestView::from_operands(kind, &operands))) - } - Object::Dictionary(d) => d.get(b"D").ok().and_then(|d| self.dest(d, depth + 1)), - Object::String(key, _) | Object::Name(key) => self.named_dest(key).and_then(|d| self.dest(d, depth + 1)), - _ => None, - } - } - - fn named_dest(&self, key: &[u8]) -> Option<&'a Object> { - let catalog = self.doc.catalog().ok()?; - if let Some(v) = self.named.get(key) { - return Some(v); - } - // PDF 1.1 style /Dests dictionary. - catalog.get(b"Dests").ok().and_then(|o| self.dict(o)).and_then(|d| d.get(key).ok()) - } - - /// A name tree's leaves keyed by their raw key bytes (first occurrence wins). - fn name_tree_raw(&self, node: &'a Dictionary, seen: &mut HashSet<*const Dictionary>, depth: u32, out: &mut HashMap, &'a Object>) { - if depth > 32 || !seen.insert(node as *const _) { - return; - } - if let Ok(Object::Array(pairs)) = node.get(b"Names").map(|o| self.resolve(o)) { - for pair in pairs.chunks(2) { - if let [k, v] = pair { - let key = match self.resolve(k) { - Object::String(s, _) => s.clone(), - Object::Name(n) => n.clone(), - _ => continue, - }; - out.entry(key).or_insert(v); - } - } - } - if let Ok(Object::Array(kids)) = node.get(b"Kids").map(|o| self.resolve(o)) { - for k in kids { - if let Some(d) = self.dict(k) { - self.name_tree_raw(d, seen, depth + 1, out); - } - } - } - } - - /// The entries of an embedded-files name tree. Each string key is spent from `text_budget` - /// (see [`MAX_LINK_ATTACHMENT_TEXT_BYTES`]), and an entry whose key does not fit is left out. - fn name_tree( - &self, - node: &'a Dictionary, - seen: &mut HashSet<*const Dictionary>, - depth: u32, - text_budget: &mut usize, - ) -> Vec<(String, &'a Object)> { - let mut out = Vec::new(); - if depth > 32 || !seen.insert(node as *const _) { - return out; - } - if let Ok(Object::Array(pairs)) = node.get(b"Names").map(|o| self.resolve(o)) { - for pair in pairs.chunks(2) { - if let [k, v] = pair { - let key = match self.resolve(k) { - Object::String(bytes, _) => match spend(bytes, text_budget, text_string) { - Some(key) => key, - None => continue, - }, - _ => String::new(), - }; - out.push((key, v)); - } - } - } - if let Ok(Object::Array(kids)) = node.get(b"Kids").map(|o| self.resolve(o)) { - for k in kids { - if let Some(d) = self.dict(k) { - out.extend(self.name_tree(d, seen, depth + 1, text_budget)); - } - } - } - out - } - - /// The first entry of an embedded-files name tree whose key is `key`, in the order - /// [`Self::name_tree`] lists them, with no key budget. It keeps no key, and converts a key - /// object or `/Names` array that several places reference only once (a second visit cannot - /// match, since the first would have returned). - fn name_tree_find(&self, node: &'a Dictionary, key: &str, seen: &mut NameTreeSeen, depth: u32) -> Option<&'a Object> { - if depth > 32 || !seen.nodes.insert(node as *const _) { - return None; - } - let names = node.get(b"Names").ok(); - if let Some(names) = names - && self.resolved_id(names).is_none_or(|id| seen.objects.insert(id)) - && let Object::Array(pairs) = self.resolve(names) - { - for pair in pairs.chunks(2) { - if let [k, v] = pair { - if self.resolved_id(k).is_some_and(|id| !seen.objects.insert(id)) { - continue; - } - let found = match self.resolve(k) { - Object::String(bytes, _) => text_string(bytes) == key, - _ => key.is_empty(), - }; - if found { - return Some(v); - } - } - } - } - if let Ok(Object::Array(kids)) = node.get(b"Kids").map(|o| self.resolve(o)) { - for k in kids { - if let Some(d) = self.dict(k) - && let Some(v) = self.name_tree_find(d, key, seen, depth + 1) - { - return Some(v); - } - } - } - None - } - - // ── outline ───────────────────────────────────────────────────────────────────────────── - - /// The window `DocInfo::outline` keeps: the first `OUTLINE_WINDOW` bookmarks in outline order, - /// nested no deeper than `OUTLINE_DEPTH`, and whether any bookmark was left out. Iterative, and - /// each bookmark is read once, so the work and memory stay within the window. - fn outline_window(&self, first: &'a Object) -> (Vec, bool) { - let mut seen = HashSet::new(); - // The listed bookmarks in outline order, with their depth. - let mut listed: Vec<(usize, OutlineItem)> = Vec::new(); - // One entry per open level: its next sibling, or `None` once that run of siblings has ended. - let mut levels: Vec> = vec![Some(first)]; - let mut more = false; - while let Some(depth) = levels.len().checked_sub(1) { - let Some(slot) = levels.get_mut(depth) else { break }; - let Some(o) = slot.take() else { - levels.pop(); - continue; - }; - if let Object::Reference(id) = o - && !seen.insert(*id) - { - continue; // cycle: this run of siblings ends here - } - let Some(d) = self.dict(o) else { continue }; - if listed.len() >= OUTLINE_WINDOW { - more = true; - break; - } - *slot = d.get(b"Next").ok(); - let dest = d - .get(b"Dest") - .ok() - .and_then(|dest| self.dest(dest, 0)) - .or_else(|| d.get(b"A").ok().and_then(|a| self.dict(a)).and_then(|a| a.get(b"D").ok()).and_then(|dest| self.dest(dest, 0))); - let (page, view) = dest.map_or((None, DestView::Top), |(p, v)| (Some(p), v)); - match d.get(b"First").ok() { - Some(child) if depth < OUTLINE_DEPTH => levels.push(Some(child)), - Some(_) => more = true, - None => {} - } - let open = d.get(b"Count").ok().and_then(|c| c.as_i64().ok()).is_some_and(|c| c > 0); - let title = self.text(d, b"Title").unwrap_or_default(); - let color = match d.get(b"C").map(|o| self.resolve(o)) { - Ok(Object::Array(c)) => outline_color(c.iter().map(|v| self.resolve(v).as_float().ok())), - _ => None, - }; - let flags = d.get(b"F").ok().and_then(|f| self.resolve(f).as_i64().ok()).unwrap_or(0); - let (italic, bold) = (flags & 1 != 0, flags & 2 != 0); - listed.push((depth, OutlineItem { title, page, view, children: Vec::new(), open, color, italic, bold })); - } - (nest_outline(listed), more) - } - - // ── page labels (ISO 32000-2 §12.4.2) ─────────────────────────────────────────────────── - - fn label_object(&self, object: &'a Object) -> Result<&'a Object, &'static str> { - self.doc.dereference(object).map(|(_, o)| o).map_err(|_| "a number tree reference is missing or cyclic") - } - - fn label_dict(&self, object: &'a Object) -> Result, &'static str> { - Ok(match self.label_object(object)? { - Object::Dictionary(d) => Some(d), - Object::Stream(s) => Some(&s.dict), - _ => None, - }) - } - - /// Apply `/PageLabels` to `pages`. `Err`: the tree is unusable and no label is applied. - /// `Ok(notes)`: labels applied, with what was repaired on the way (a duplicated start page, - /// a range whose labels are too long to show). - fn page_labels(&self, catalog: &Dictionary, pages: &mut [PageInfo]) -> Result, &'static str> { - let mut notes = Vec::new(); - let Ok(tree) = catalog.get(b"PageLabels") else { return Ok(notes) }; - let Some(tree) = self.label_dict(tree)? else { return Ok(notes) }; - let mut ranges: BTreeMap = BTreeMap::new(); - let mut stack = vec![(tree, 0usize)]; - let mut seen = HashSet::new(); - let mut left = MAX_LABEL_TREE_WORK; - while let Some((node, depth)) = stack.pop() { - label_work(&mut left, 1)?; - if depth > MAX_LABEL_TREE_DEPTH { - return Err("the number tree is too deep"); - } - if !seen.insert(node as *const Dictionary) { - return Err("the number tree repeats a node or contains a cycle"); - } - if let Ok(nums) = node.get(b"Nums") - && let Object::Array(nums) = self.label_object(nums)? - { - label_work(&mut left, nums.len().div_ceil(2))?; - for pair in nums.chunks(2) { - if let [k, v] = pair - && let (Ok(start), Some(d)) = (self.label_object(k)?.as_i64(), self.label_dict(v)?) - && let Ok(start) = usize::try_from(start) - && ranges.insert(start, d).is_some() - { - // The later range wins, as in readers that overwrite. - notes.push(format!("a range starts on page {} twice; the later one is used", start.saturating_add(1))); - } - } - } - if let Ok(kids) = node.get(b"Kids") - && let Object::Array(kids) = self.label_object(kids)? - { - label_work(&mut left, kids.len())?; - if kids.len() > MAX_LABEL_TREE_WORK.saturating_sub(stack.len()) { - return Err("the number tree has too many pending nodes"); - } - if !kids.is_empty() && depth == MAX_LABEL_TREE_DEPTH { - return Err("the number tree is too deep"); - } - for kid in kids { - if let Some(d) = self.label_dict(kid)? { - stack.push((d, depth + 1)); - } - } - } - } - // Decode each bounded prefix once, rather than once for every page in its range. - let mut specs = BTreeMap::new(); - let mut prefix_total = 0usize; - for (start, d) in ranges { - let first = d.get(b"St").ok().and_then(|o| self.resolve(o).as_i64().ok()).unwrap_or(1).max(1) as u64; - let prefix = match d.get(b"P").ok().map(|o| self.resolve(o)) { - Some(Object::String(raw, _)) => { - if raw.len() > MAX_PREFIX_BYTES { - return Err("a raw prefix exceeds 2050 bytes"); - } - // A label prefix is literal text, not trimmed document metadata. - text_string(raw).trim_matches('\0').to_string() - } - Some(Object::Name(raw)) => { - if raw.len() > MAX_PREFIX_BYTES { - return Err("a raw prefix exceeds 2050 bytes"); - } - // Retain the existing name compatibility and boundary-NUL handling. - String::from_utf8_lossy(raw).trim_matches('\0').to_string() - } - _ => String::new(), - }; - if prefix.len() > MAX_LABEL_BYTES { - return Err("a decoded prefix exceeds 1024 UTF-8 bytes"); - } - // A prefix object shared by many ranges is decoded once per range: count every copy. - prefix_total = prefix_total.saturating_add(prefix.len()); - if prefix_total > MAX_PREFIX_TOTAL_BYTES { - return Err("decoded prefixes exceed 4 MiB in total"); - } - let style = d.get(b"S").ok().and_then(|o| self.resolve(o).as_name().ok()); - specs.insert(start, (first, prefix, style)); - } - let mut staged = Vec::new(); - let mut total = 0usize; - for (i, _) in pages.iter().enumerate() { - let Some((&start, (first, prefix, style))) = specs.range(..=i).next_back() else { continue }; - let n = if matches!(*style, Some(b"D" | b"R" | b"r" | b"A" | b"a")) { - let offset = u64::try_from(i - start).map_err(|_| "a page number is too large")?; - first.checked_add(offset).ok_or("a page number overflows")? - } else { - 0 // /St is unused when the label consists only of its prefix. - }; - let remaining = MAX_LABEL_BYTES - prefix.len(); - let number = match *style { - Some(b"D") => Some(n.to_string()), - Some(b"R" | b"r") => roman(n, remaining), - Some(b"A" | b"a") => alpha(n, remaining), - _ => Some(String::new()), - } - .filter(|number| number.len() <= remaining); - // A label too long to show keeps that page's physical number; other ranges stay. - let Some(mut number) = number else { - let note = format!( - "labels from page {} exceed {MAX_LABEL_BYTES} UTF-8 bytes; those pages show their physical page numbers", - start.saturating_add(1) - ); - if !notes.contains(¬e) { - notes.push(note); - } - continue; - }; - if matches!(*style, Some(b"R" | b"A")) { - number.make_ascii_uppercase(); - } - let mut label = prefix.clone(); - label.push_str(&number); - if label.is_empty() { - continue; // Keep the existing physical number for an empty custom label. - } - total = total.checked_add(label.len()).ok_or("the total label size overflows")?; - if total > MAX_LABEL_TOTAL_BYTES { - return Err("custom labels exceed 4 MiB in total"); - } - staged.push((i, label)); - } - // A malformed later range must not leave a partially applied custom label sequence. - for (i, label) in staged { - if let Some(page) = pages.get_mut(i) { - page.label = label; - } - } - Ok(notes) - } - - // ── annotations ───────────────────────────────────────────────────────────────────────── - - /// `text_budget` is what is left of [`MAX_LINK_ATTACHMENT_TEXT_BYTES`]. - fn annotations(&self, info: &mut DocInfo, text_budget: &mut usize) { - let mut pages: Vec<(ObjectId, usize)> = self.page_index.iter().map(|(&id, &page)| (id, page)).collect(); - pages.sort_unstable_by_key(|&(_, page)| page); - // An annotation, or a whole `/Annots` array, that several entries or pages list is on the - // first of them (§12.5.2 allows one page per annotation). Both are told apart by the object - // a reference resolves to, since an indirect object may itself hold a reference. Where each - // annotation is listed is collected before any is read, so a reply's `/IRT` finds its - // parent by reference even when the parent comes later. - let mut seen_arrays = HashSet::new(); - let mut slots: HashMap = HashMap::new(); - let mut entries = Vec::new(); - for (id, page) in pages { - let Ok(page_dict) = self.doc.get_dictionary(id) else { continue }; - if let Ok(annots) = page_dict.get(b"Annots") - && let Some(r) = self.resolved_id(annots) - && !seen_arrays.insert(r) - { - continue; - } - let Ok(Object::Array(annots)) = page_dict.get(b"Annots").map(|o| self.resolve(o)) else { continue }; - for (index, a) in annots.iter().enumerate() { - if let Some(r) = self.resolved_id(a) { - let Entry::Vacant(slot) = slots.entry(r) else { continue }; - slot.insert((page, index)); - } - entries.push((page, index, a)); - } - } - let mut kept = 0usize; - // Once the comment budget is spent, comments are no longer built, but links and attachments - // are still listed. - let mut comments_full = false; - for (page, index, a) in entries { - let Some(d) = self.dict(a) else { continue }; - let Some(subtype) = self.name(d, b"Subtype") else { continue }; - if subtype == "FileAttachment" - && let Ok(fs) = d.get(b"FS") - { - let fallback = |budget: &mut usize| self.budgeted_text(d, b"Contents", budget); - info.attachments.push(self.attachment(fs, AttachmentSource::Annotation { page, index }, text_budget, fallback)); - } - if subtype == "Link" { - if let Some(link) = self.link(page, index, d, text_budget) { - if matches!(&link.target, LinkTarget::Other(s) if s == "JavaScript") { - info.has_javascript = true; - } - info.links.push(link); - } - continue; - } - // Not comments: form widgets, pop-ups and non-markup annotations such as the - // Screen annotation that drives a LaTeX `animate` player (keep in step with - // `pdfcraft_annot::is_comment_subtype`). - if matches!(subtype.as_str(), "Widget" | "Popup" | "Screen" | "Movie" | "RichMedia" | "3D" | "PrinterMark" | "TrapNet" | "Watermark") { - continue; - } - if comments_full { - continue; - } - let rect = rect4(self.resolve(d.get(b"Rect").unwrap_or(&Object::Null))); - let mut color = match d.get(b"C").map(|o| self.resolve(o)) { - Ok(Object::Array(c)) if c.len() == 3 => { - let f = |i: usize| c[i].as_float().unwrap_or(0.0); - Some([f(0), f(1), f(2)]) - } - _ => None, - }; - // A text box's /C is its background; its text colour is in /DA. - if subtype == "FreeText" - && let Some(da) = self.text(d, b"DA") - { - let t: Vec<&str> = da.split_whitespace().collect(); - if let Some(i) = t.iter().position(|x| *x == "rg") - && i >= 3 - { - let f = |k: usize| t[k].parse::().unwrap_or(0.0); - color = Some([f(i - 3), f(i - 2), f(i - 1)]); - } - } - let quads = match d.get(b"QuadPoints").map(|o| self.resolve(o)) { - Ok(Object::Array(q)) => { - let v: Vec = q.iter().map(|o| o.as_float().unwrap_or(0.0)).collect(); - v.as_chunks::<8>().0.to_vec() - } - _ => Vec::new(), - }; - let in_reply_to = d.get(b"IRT").ok().and_then(|o| self.dict(o)).and_then(|p| self.text(p, b"NM")); - let parent = d.get(b"IRT").ok().and_then(|o| self.resolved_id(o)).and_then(|r| slots.get(&r).copied()); - let fill_sign = self.is_fill_sign(d, &subtype); - // Keep in step with `pdfcraft_annot::summaries`. - let key: &[u8] = if subtype == "Line" { b"L" } else { b"Vertices" }; - let points = match d.get(key).map(|o| self.resolve(o)) { - Ok(Object::Array(v)) if matches!(subtype.as_str(), "Line" | "PolyLine" | "Polygon") => { - let v: Vec = v.iter().map(|o| o.as_float().unwrap_or(0.0)).collect(); - let pts: Vec<[f32; 2]> = v.as_chunks::<2>().0.to_vec(); - if subtype == "Line" && pts.len() != 2 { Vec::new() } else { pts } - } - _ => Vec::new(), - }; - let annotation = Annotation { - page, - subtype, - author: self.text(d, b"T"), - // Acrobat ends lines with CR, which the interface would draw as nothing (keep - // in step with `pdfcraft_annot::summaries`). - contents: self.text(d, b"Contents").map(|s| s.replace("\r\n", "\n").replace('\r', "\n")), - modified: self.text(d, b"M").map(|m| pretty_date(&m)), - name: self.text(d, b"NM"), - in_reply_to, - parent, - grouped: self.name(d, b"RT").as_deref() == Some("Group"), - rect, - color, - index, - state: self.text(d, b"State"), - quads, - locked: d.get(b"F").ok().and_then(|f| self.resolve(f).as_i64().ok()).unwrap_or(0) & 128 != 0, - intent: self.name(d, b"IT"), - fill_sign, - no_rotate: d.get(b"F").is_ok_and(|f| draw_flags(self.resolve(f)) & 16 != 0), - points, - }; - kept = kept.saturating_add(retained(&annotation)); - if kept > MAX_ANNOTATION_BYTES { - comments_full = true; - continue; - } - info.annotations.push(annotation); - } - info.annotations.sort_by(|a, b| a.page.cmp(&b.page).then(b.rect[3].total_cmp(&a.rect[3]))); - link_replies(&mut info.annotations); - } - - fn link(&self, page: usize, index: usize, d: &Dictionary, text_budget: &mut usize) -> Option { - let rect = rect4(self.resolve(d.get(b"Rect").ok()?)); - let target = if let Ok(dest) = d.get(b"Dest") { - let (page, view) = self.dest(dest, 0)?; - LinkTarget::Page(page, view) - } else { - let a = d.get(b"A").ok().and_then(|a| self.dict(a))?; - match self.name(a, b"S").as_deref() { - Some("GoTo") => { - let (page, view) = self.dest(a.get(b"D").ok()?, 0)?; - LinkTarget::Page(page, view) - } - Some("URI") => { - let uri = a.get(b"URI").ok().and_then(|u| self.resolve(u).as_str().ok())?; - match spend(uri, text_budget, |b| String::from_utf8_lossy(b).into_owned()) { - Some(uri) => LinkTarget::Uri(uri), - None => LinkTarget::Other("URI".into()), - } - } - Some("SetOCGState") => self.layer_state(a), - Some("GoToR") => self.remote_go_to(a).unwrap_or_else(|| LinkTarget::Other("GoToR".into())), - Some("Launch") => self.launch_pdf(a).unwrap_or_else(|| LinkTarget::Other("Launch".into())), - Some(other) => LinkTarget::Other(other.to_string()), - None => return None, - } - }; - Some(Link { page, index, rect, target }) - } - - fn remote_go_to(&self, action: &Dictionary) -> Option { - let file = self.action_file(action)?; - let destination = match action.get(b"D") { - Err(_) => RemoteDestination::Default, - Ok(o) => match self.resolve(o) { - Object::Array(items) => { - let page = items.first().and_then(|o| self.resolve(o).as_i64().ok()).and_then(|n| usize::try_from(n).ok())?; - let kind = items.get(1).map(|o| self.resolve(o)).and_then(|o| o.as_name().ok()); - let operands: [Option; 4] = std::array::from_fn(|i| items.get(i + 2).and_then(|o| self.resolve(o).as_float().ok())); - RemoteDestination::Page { page, view: DestView::from_operands(kind, &operands) } - } - Object::Name(name) if name.len() <= MAX_REMOTE_DESTINATION_NAME_BYTES => { - let name = String::from_utf8_lossy(name).into_owned(); - if name.is_empty() || name.len() > MAX_REMOTE_DESTINATION_NAME_BYTES { - return None; - } - RemoteDestination::Named(name) - } - Object::String(bytes, _) if bytes.len() <= MAX_REMOTE_DESTINATION_NAME_BYTES => { - let name = text_string(bytes); - if name.is_empty() || name.len() > MAX_REMOTE_DESTINATION_NAME_BYTES { - return None; - } - RemoteDestination::Named(name) - } - _ => return None, - }, - }; - Some(LinkTarget::RemoteGoTo { launch: false, file, destination, new_window: self.new_window(action) }) - } - - /// The non-empty local file an action's `/F` names (a string or a file specification), shared - /// by GoToR and Launch. A URL file system (`/FS /URL`) names no local file. - fn action_file(&self, action: &Dictionary) -> Option { - if self.name(action, b"FS").as_deref() == Some("URL") { - return None; - } - let file = match self.resolve(action.get(b"F").ok()?) { - Object::String(bytes, _) => limited_pdf_string(bytes, MAX_REMOTE_FILE_SPEC_BYTES)?, - Object::Dictionary(spec) => { - if self.name(spec, b"FS").as_deref() == Some("URL") { - return None; - } - spec.get(b"UF") - .ok() - .and_then(|o| self.pdf_text(o, MAX_REMOTE_FILE_SPEC_BYTES)) - .or_else(|| spec.get(b"F").ok().and_then(|o| self.pdf_text(o, MAX_REMOTE_FILE_SPEC_BYTES)))? - } - _ => return None, - }; - (!file.is_empty()).then_some(file) - } - - fn new_window(&self, action: &Dictionary) -> Option { - match action.get(b"NewWindow").map(|o| self.resolve(o)) { - Ok(Object::Boolean(value)) => Some(*value), - _ => None, - } - } - - /// A Launch action (ISO 32000-2 §12.6.4.6, Table 207) whose `/F` names a `.pdf` file, read as - /// the remote Go-To to that file's first page that it amounts to (#4168). Launching anything - /// else, or with the deprecated `/Win` launch parameters (an application, an operation, its - /// arguments), is not followed: a document must not start programs. `\` separators, which - /// Windows writers put in such paths, are read as `/` (§7.11.2.1 separates components with - /// `/`) when the path has no `/` of its own. Launch has no `/D`, so one present (written - /// for GoToR, or junk) is ignored rather than checked as a destination. - fn launch_pdf(&self, action: &Dictionary) -> Option { - if action.has(b"Win") { - return None; - } - let file = self.action_file(action)?; - let new_window = self.new_window(action); - let file = if file.contains('/') { file } else { file.replace('\\', "/") }; - let pdf = file.rsplit('/').next().is_some_and(|name| name.len() > 4 && name.to_ascii_lowercase().ends_with(".pdf")); - pdf.then_some(LinkTarget::RemoteGoTo { launch: true, file, destination: RemoteDestination::Default, new_window }) - } - - fn pdf_text(&self, o: &Object, max_bytes: usize) -> Option { - match self.resolve(o) { - Object::String(bytes, _) => limited_pdf_string(bytes, max_bytes), - _ => None, - } - } - - /// A set-OCG-state action: `/State` is `ON`, `OFF` or `Toggle`, each followed by the groups - /// it applies to; `/PreserveRB` is true unless it is `false`. - fn layer_state(&self, a: &Dictionary) -> LinkTarget { - let mut changes = Vec::new(); - if let Ok(Object::Array(items)) = a.get(b"State").map(|o| self.resolve(o)) { - let mut op = None; - for item in items.iter().take(MAX_LAYER_STATE) { - match item { - Object::Name(n) => { - op = match n.as_slice() { - b"ON" => Some(LayerOp::On), - b"OFF" => Some(LayerOp::Off), - b"Toggle" => Some(LayerOp::Toggle), - _ => None, - } - } - Object::Reference(id) => changes.extend(op.map(|op| (op, *id))), - _ => {} - } - } - } - let preserve_rb = !matches!(a.get(b"PreserveRB").map(|o| self.resolve(o)), Ok(Object::Boolean(false))); - LinkTarget::SetLayers { changes, preserve_rb } - } - - // ── form fields ───────────────────────────────────────────────────────────────────────── - - fn field( - &self, - o: &'a Object, - parent: Option<(&str, Option, Option)>, - out: &mut Vec, - seen: &mut HashSet, - depth: u32, - ) { - if depth > 32 { - return; - } - if let Object::Reference(id) = o - && !seen.insert(*id) - { - return; - } - let Some(d) = self.dict(o) else { return }; - let (parent_name, parent_ft, parent_ff) = parent.unwrap_or(("", None, None)); - let partial = self.text(d, b"T"); - let full = match (&partial, parent_name.is_empty()) { - (Some(t), true) => t.clone(), - (Some(t), false) => format!("{parent_name}.{t}"), - (None, _) => parent_name.to_string(), - }; - let ft = self.name(d, b"FT").or(parent_ft); - let ff = d.get(b"Ff").ok().and_then(|x| x.as_i64().ok()).or(parent_ff); - // Non-terminal: has kids that are fields (they carry /T). Widgets-only kids are terminal. - let kids = match d.get(b"Kids").map(|k| self.resolve(k)) { - Ok(Object::Array(k)) => k.as_slice(), - _ => &[], - }; - let field_kids: Vec<_> = kids.iter().filter(|k| self.dict(k).is_some_and(|kd| kd.get(b"T").is_ok())).collect(); - if !field_kids.is_empty() { - for k in field_kids { - self.field(k, Some((&full, ft.clone(), ff)), out, seen, depth + 1); - } - return; - } - let flags = ff.unwrap_or(0); - let kind = match ft.as_deref() { - Some("Tx") => FieldKind::Text, - Some("Btn") if flags & (1 << 16) != 0 => FieldKind::PushButton, - Some("Btn") if flags & (1 << 15) != 0 => FieldKind::Radio, - Some("Btn") => FieldKind::CheckBox, - Some("Ch") if flags & (1 << 17) != 0 => FieldKind::Combo, - Some("Ch") => FieldKind::List, - Some("Sig") => FieldKind::Signature, - _ => FieldKind::Unknown, - }; - let value = match d.get(b"V").map(|v| self.resolve(v)) { - Ok(Object::Name(n)) => Some(String::from_utf8_lossy(n).into_owned()), - Ok(Object::String(bytes, _)) => Some(text_string(bytes)), - Ok(Object::Array(a)) => Some( - a.iter() - .filter_map(|x| match self.resolve(x) { - Object::String(bytes, _) => Some(text_string(bytes)), - _ => None, - }) - .collect::>() - .join(", "), - ), - Ok(Object::Dictionary(_)) if kind == FieldKind::Signature => Some("signed".into()), - _ => None, - }; - // The widget is either this dict (merged field/widget) or its first kid. - let widget = if d.get(b"Rect").is_ok() { Some(d) } else { kids.first().and_then(|k| self.dict(k)) }; - let page = widget.and_then(|w| w.get(b"P").ok()).and_then(|p| self.page_of(p)).or_else(|| self.find_widget_page(o, kids)); - let has_actions = d.get(b"AA").is_ok() || widget.is_some_and(|w| w.get(b"AA").is_ok() || w.get(b"A").is_ok()); - let rect = widget.and_then(|w| w.get(b"Rect").ok()).map(|r| rect4(self.resolve(r))); - out.push(Field { name: full, kind, value, page, tooltip: self.text(d, b"TU"), has_actions, rect }); - } - - /// The first page whose `/Annots` lists the field or one of its kids. - fn find_widget_page(&self, field: &Object, kids: &[Object]) -> Option { - let pages = self.annotation_pages.get_or_init(|| self.annotation_pages()); - std::iter::once(field).chain(kids).filter_map(|o| if let Object::Reference(id) = o { pages.get(id).copied() } else { None }).min() - } - - /// Each annotation listed in a page's `/Annots`, with the first page that lists it. - fn annotation_pages(&self) -> HashMap { - let mut pages = HashMap::new(); - for (idx, &pid) in self.page_ids.iter().enumerate() { - let Some(annots) = self.doc.get_dictionary(pid).ok().and_then(|d| d.get(b"Annots").ok()) else { continue }; - let Ok(annots) = self.resolve(annots).as_array() else { continue }; - for a in annots { - if let Object::Reference(id) = a { - pages.entry(*id).or_insert(idx); - } - } - } - pages - } - - /// The topmost field above widget `id` (itself without a `/Parent`), or `None` when it or an - /// ancestor is already reachable from the `/Fields` tree, or a `/Parent` loop hides the top. - fn unlisted_field(&self, id: ObjectId, seen: &HashSet) -> Option { - let mut top = id; - let mut visited = HashSet::from([id]); - loop { - let o = Object::Reference(top); - let d = self.dict(&o)?; - let parent = match d.get(b"Parent") { - Ok(Object::Reference(p)) => Some(*p), - _ => None, - }; - let Some(p) = parent else { - return if seen.contains(&top) { None } else { Some(top) }; - }; - if !visited.insert(p) || visited.len() > 64 { - return None; - } - if seen.contains(&p) { - return None; - } - top = p; - } - } - - // ── optional content ──────────────────────────────────────────────────────────────────── - - fn layers(&self, catalog: &Dictionary, out: &mut Vec) { - let Some(props) = catalog.get(b"OCProperties").ok().and_then(|o| self.dict(o)) else { return }; - let config = props.get(b"D").ok().and_then(|o| self.dict(o)); - let off: HashSet = config - .and_then(|c| c.get(b"OFF").ok()) - .and_then(|o| self.resolve(o).as_array().ok()) - .map(|a| a.iter().filter_map(|x| x.as_reference().ok()).collect()) - .unwrap_or_default(); - let base_off = config.and_then(|c| self.name(c, b"BaseState")).as_deref() == Some("OFF"); - let on: HashSet = config - .and_then(|c| c.get(b"ON").ok()) - .and_then(|o| self.resolve(o).as_array().ok()) - .map(|a| a.iter().filter_map(|x| x.as_reference().ok()).collect()) - .unwrap_or_default(); - let Ok(Object::Array(ocgs)) = props.get(b"OCGs").map(|o| self.resolve(o)) else { return }; - for g in ocgs { - let Some(d) = self.dict(g) else { continue }; - let id = g.as_reference().ok(); - let visible = match id { - Some(id) if off.contains(&id) => false, - Some(id) if on.contains(&id) => true, - _ => !base_off, - }; - let Some(id) = id else { continue }; - out.push(Layer { id, name: self.text(d, b"Name").unwrap_or_else(|| "Layer".into()), visible, initial: visible }); - } - } - - /// The default configuration's radio-button groups (`/RBGroups`) of two or more layers. - fn layer_groups(&self, catalog: &Dictionary) -> Vec> { - let groups = catalog - .get(b"OCProperties") - .ok() - .and_then(|o| self.dict(o)) - .and_then(|p| p.get(b"D").ok()) - .and_then(|o| self.dict(o)) - .and_then(|c| c.get(b"RBGroups").ok()) - .and_then(|o| self.resolve(o).as_array().ok()); - let mut left = MAX_LAYER_GROUP_ENTRIES; - let mut out = Vec::new(); - for g in groups.into_iter().flatten() { - let Ok(members) = self.resolve(g).as_array() else { continue }; - let group: Vec = members.iter().filter_map(|x| x.as_reference().ok()).take(left).collect(); - left = left.saturating_sub(group.len()); - if group.len() > 1 { - out.push(group); - } - if left == 0 { - break; - } - } - out - } - - fn fonts(&self, out: &mut Vec) { - let mut seen = HashSet::new(); - for &pid in &self.page_ids { - let Ok(fonts) = self.doc.get_page_fonts(pid) else { continue }; - for (_, f) in fonts { - let base = f - .get(b"BaseFont") - .ok() - .and_then(|o| o.as_name().ok()) - .map(|n| String::from_utf8_lossy(n).into_owned()) - .unwrap_or_else(|| "(unnamed)".into()); - let kind = self.name(f, b"Subtype").unwrap_or_default(); - let encoding = match f.get(b"Encoding").map(|o| self.resolve(o)) { - Ok(Object::Name(n)) => Some(String::from_utf8_lossy(n).into_owned()), - Ok(Object::Dictionary(_)) => Some("Custom".into()), - Ok(Object::Stream(_)) => Some("Embedded CMap".into()), - _ => None, - }; - // Embedded: FontFile/2/3 in the descriptor (for Type0, in the descendant font). - let descriptor_of = |d: &'a Dictionary| d.get(b"FontDescriptor").ok().and_then(|o| self.dict(o)); - let descendant = - f.get(b"DescendantFonts").ok().and_then(|o| self.resolve(o).as_array().ok()).and_then(|a| a.first()).and_then(|o| self.dict(o)); - let desc = descriptor_of(f).or_else(|| descendant.and_then(descriptor_of)); - let embedded = - kind == "Type3" || desc.is_some_and(|d| d.get(b"FontFile").is_ok() || d.get(b"FontFile2").is_ok() || d.get(b"FontFile3").is_ok()); - // What the substitution chain needs beyond the name: the descriptor flags, and for - // a composite font the script its descendant declares (ISO 32000-2 9.7.3). - let flags = desc - .and_then(|d| d.get(b"Flags").ok()) - .and_then(|o| self.resolve(o).as_i64().ok()) - .and_then(|n| u32::try_from(n).ok()) - .unwrap_or(0); - let ordering = - descendant.and_then(|d| d.get(b"CIDSystemInfo").ok()).and_then(|o| self.dict(o)).and_then(|c| self.text(c, b"Ordering")); - let subset = base.len() > 7 && base.as_bytes()[6] == b'+' && base[..6].bytes().all(|b| b.is_ascii_uppercase()); - let name = if subset { base[7..].to_string() } else { base }; - let chosen = pdfcraft_fonts::fallback::substitute(&pdfcraft_fonts::fallback::Request { - base_font: &name, - embedded, - flags, - ordering: ordering.as_deref(), - }); - let info = FontInfo { - name, - kind, - embedded, - subset, - encoding, - substitute: chosen.face_name(), - substitution: (!chosen.why.is_faithful()).then(|| chosen.why.note().to_owned()), - }; - if seen.insert((info.name.clone(), info.kind.clone(), info.embedded)) { - out.push(info); - } - } - } - out.sort_by(|a, b| a.name.cmp(&b.name)); - } - - /// The attachment's name is the file specification's, else `fallback`'s, else a generic one. - fn attachment( - &self, - spec: &Object, - source: AttachmentSource, - text_budget: &mut usize, - fallback: impl FnOnce(&mut usize) -> Option, - ) -> Attachment { - let d = self.dict(spec); - let size = - d.and_then(|d| d.get(b"EF").ok()).and_then(|o| self.dict(o)).and_then(|ef| ef.get(b"F").ok().or_else(|| ef.get(b"UF").ok())).and_then( - |f| match self.resolve(f) { - Object::Stream(s) => s - .dict - .get(b"Params") - .ok() - .and_then(|p| self.dict(p)) - .and_then(|p| p.get(b"Size").ok()) - .and_then(|s| s.as_i64().ok()) - .map(|s| s as usize) - .or(Some(s.content.len())), - _ => None, - }, - ); - let display = d - .and_then(|d| self.budgeted_text(d, b"UF", text_budget).or_else(|| self.budgeted_text(d, b"F", text_budget))) - .or_else(|| fallback(text_budget)) - .unwrap_or_else(|| "attachment".into()); - Attachment { name: display, description: d.and_then(|d| self.budgeted_text(d, b"Desc", text_budget)), size, source } - } -} - -/// A text string's value, decoded by the same rules as the engine's object model -/// ([`pdfcraft_cos::PdfString::to_text`]) so the panels and outline editing agree. -fn text_string(bytes: &[u8]) -> String { - pdfcraft_cos::PdfString::literal(bytes).to_text() -} - -/// What [`Inspector::name_tree_find`] has visited: node dictionaries, and the key and `/Names` -/// array objects it reached by reference. -#[derive(Default)] -struct NameTreeSeen { - nodes: HashSet<*const Dictionary>, - objects: HashSet, -} - -/// A string or name value as text, without surrounding NULs and white space. -fn trimmed_text(o: &Object) -> Option { - let s = match o { - Object::String(bytes, _) => text_string(bytes), - Object::Name(n) => String::from_utf8_lossy(n).into_owned(), - _ => return None, - }; - Some(s.trim_matches('\0').trim().to_string()) -} - -/// `convert(bytes)` when the result fits in `budget`, which it then spends by the longer of the raw -/// and converted lengths. Bytes longer than the budget are not converted. A conversion that comes -/// out too long spends the raw length, so a string many entries share is converted only until the -/// budget runs out. -fn spend(bytes: &[u8], budget: &mut usize, convert: impl FnOnce(&[u8]) -> String) -> Option { - if bytes.len() > *budget { - return None; - } - let text = convert(bytes); - if text.len() > *budget { - *budget = budget.saturating_sub(bytes.len()); - return None; - } - // The conversion's work follows the raw length, even when the text it keeps is shorter. - *budget = budget.saturating_sub(text.len().max(bytes.len())); - Some(text) -} - -fn limited_pdf_string(bytes: &[u8], max_bytes: usize) -> Option { - if bytes.len() > max_bytes { - return None; - } - let text = text_string(bytes); - (text.len() <= max_bytes).then_some(text) -} - -/// A bookmark title's colour from its `/C` entries, each kept within 0–1 (ISO 32000-2 §12.3.3, -/// Table 151: "An array of three numbers in the range 0.0 to 1.0, representing the components in -/// the DeviceRGB colour space"). Black, the default, and anything that isn't three numbers give -/// `None`. -pub(crate) fn outline_color(values: impl Iterator>) -> Option<[f32; 3]> { - let v: Vec> = values.take(4).collect(); - let [Some(r), Some(g), Some(b)] = v.as_slice() else { return None }; - // A number past the range, however large, is kept at its edge. - let rgb = [*r, *g, *b].map(|c| c.clamp(0.0, 1.0)); - (rgb != [0.0; 3]).then_some(rgb) -} - -/// Nests a pre-order list of `(depth, item)` into the outline tree. -fn nest_outline(listed: Vec<(usize, OutlineItem)>) -> Vec { - let mut roots = Vec::new(); - // The open ancestors, outermost first; each collects its children until it closes. - let mut open: Vec<(usize, OutlineItem)> = Vec::new(); - for (depth, item) in listed { - while open.last().is_some_and(|(d, _)| *d >= depth) { - close_outline(&mut open, &mut roots); - } - open.push((depth, item)); - } - while !open.is_empty() { - close_outline(&mut open, &mut roots); - } - roots -} - -/// Closes the innermost open bookmark: it joins its parent's children, or the top level. -fn close_outline(open: &mut Vec<(usize, OutlineItem)>, roots: &mut Vec) { - if let Some((_, item)) = open.pop() { - match open.last_mut() { - Some((_, parent)) => parent.children.push(item), - None => roots.push(item), - } - } -} - -/// An annotation's `/F` as the renderer reads it: a real is truncated, and a value that isn't a -/// `u32` counts as no flags. -fn draw_flags(o: &Object) -> u32 { - let f = match o { - Object::Integer(i) => *i, - Object::Real(r) => *r as i64, - _ => 0, - }; - u32::try_from(f).unwrap_or(0) -} - -fn rect4(o: &Object) -> [f32; 4] { - match o { - Object::Array(a) if a.len() == 4 => { - let v: Vec = a.iter().map(|x| x.as_float().unwrap_or(0.0)).collect(); - [v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3])] - } - _ => [0.0; 4], - } -} - -fn label_work(left: &mut usize, count: usize) -> Result<(), &'static str> { - *left = left.checked_sub(count).ok_or("the number tree has too many entries")?; - Ok(()) -} - -/// `D:20260930104512-04'00'` → `2026-09-30 10:45`. -/// A PDF date (`D:20261001123000Z`) as "2026-10-01 12:30"; other strings unchanged. -pub fn pretty_date(s: &str) -> String { - let d = s.trim_start_matches("D:"); - if let Some(d) = d.get(..12).filter(|d| d.bytes().all(|b| b.is_ascii_digit())) { - // Twelve ASCII digits make every slice below a UTF-8 character boundary. - format!("{}-{}-{} {}:{}", &d[0..4], &d[4..6], &d[6..8], &d[8..10], &d[10..12]) - } else { - s.to_string() - } -} - -/// Fetch an attachment's bytes (decoded). Capped at 1 GiB to defuse decompression bombs. -pub fn attachment_data(bytes: &[u8], password: Option<&str>, att: &Attachment) -> Result, String> { - let run = || -> Result, String> { - let options = load_options(password); - let doc = Document::load_mem_with_options(bytes, options).map_err(|e| e.to_string())?; - let insp = Inspector::new(&doc); - let spec: &Object = match &att.source { - AttachmentSource::Document { key } => { - let names = doc.catalog().ok().and_then(|c| c.get(b"Names").ok()).and_then(|o| insp.dict(o)).ok_or("no /Names")?; - let ef = names.get(b"EmbeddedFiles").ok().and_then(|o| insp.dict(o)).ok_or("no /EmbeddedFiles")?; - insp.name_tree_find(ef, key, &mut NameTreeSeen::default(), 0).ok_or("attachment not found")? - } - AttachmentSource::Annotation { page, index } => { - let (&pid, _) = insp.page_index.iter().find(|(_, i)| **i == *page).ok_or("page not found")?; - let annots = doc.get_dictionary(pid).map_err(|e| e.to_string())?.get(b"Annots").map_err(|e| e.to_string())?; - let a = insp.resolve(annots).as_array().map_err(|e| e.to_string())?.get(*index).ok_or("annotation not found")?; - insp.dict(a).and_then(|d| d.get(b"FS").ok()).ok_or("annotation has no file")? - } - }; - let fs = insp.dict(spec).ok_or("bad file specification")?; - let ef = fs.get(b"EF").ok().and_then(|o| insp.dict(o)).ok_or("the file is not embedded (external reference)")?; - let stream = ef.get(b"UF").ok().or_else(|| ef.get(b"F").ok()).map(|o| insp.resolve(o)).ok_or("no embedded stream")?; - let stream = stream.as_stream().map_err(|e| e.to_string())?; - if stream.dict.get(b"Filter").is_ok() { - stream.decompressed_content_with_limit(1 << 30).map_err(|e| e.to_string()) - } else { - Ok(stream.content.clone()) - } - }; - catch_unwind(AssertUnwindSafe(run)).unwrap_or_else(|p| Err(format!("reading the attachment crashed: {}", crate::raster::panic_message(&p)))) -} - -/// An XFA packet's bytes as text: UTF-16 (little- or big-endian, told by a byte order mark or by -/// how `<` is written) or UTF-8, as `pdfcraft_xfa::packets::decode` reads them. Lossy: only one -/// element is looked for. -fn xfa_packet_text(bytes: &[u8]) -> String { - let utf16 = |b: &[u8], from: fn([u8; 2]) -> u16| { - let units = b.as_chunks::<2>().0.iter().map(|c| from(*c)); - char::decode_utf16(units).map(|c| c.unwrap_or(char::REPLACEMENT_CHARACTER)).collect::() - }; - match bytes { - [0xFF, 0xFE, rest @ ..] => utf16(rest, u16::from_le_bytes), - [0xFE, 0xFF, rest @ ..] => utf16(rest, u16::from_be_bytes), - [b'<', 0, ..] => utf16(bytes, u16::from_le_bytes), - [0, b'<', ..] => utf16(bytes, u16::from_be_bytes), - [0xEF, 0xBB, 0xBF, rest @ ..] => String::from_utf8_lossy(rest).into_owned(), - _ => String::from_utf8_lossy(bytes).into_owned(), - } -} - -/// One markup tag found by [`next_tag`]: where it starts (`<`) and ends (after `>`), whether it -/// closes (``) or closes itself (``), and its name without a namespace prefix. -struct Tag<'a> { - start: usize, - end: usize, - close: bool, - empty: bool, - local: &'a str, -} - -/// The next element tag in `xml` at or after `from`, skipping comments, CDATA sections, -/// processing instructions and declarations. A byte scan, not an XML parser: enough to find one -/// known element in an XFA packet, whose tags Designer writes with a line break before each `>`. -fn next_tag(xml: &str, mut from: usize) -> Option> { - loop { - let lt = from + xml.get(from..)?.find('<')?; - let rest = xml.get(lt..)?; - let skip_to = |end: &str| rest.find(end).map(|i| lt + i + end.len()); - if rest.starts_with("")?; - continue; - } - if rest.starts_with("")?; - continue; - } - if rest.starts_with("")?; - continue; - } - let gt = lt + rest.find('>')?; - let inner = xml.get(lt + 1..gt)?; - let (close, inner) = match inner.strip_prefix('/') { - Some(i) => (true, i), - None => (false, inner), - }; - let empty = !close && inner.ends_with('/'); - let name = inner.split(|c: char| c.is_whitespace() || c == '/').next().unwrap_or_default(); - let local = name.rsplit(':').next().unwrap_or(name); - return Some(Tag { start: lt, end: gt + 1, close, empty, local }); - } -} - -/// What `…` holds: the content of the first top-level element of `xml` called `name` -/// (any prefix). Elements nested in other elements are not looked at. `None` when there is none, -/// it closes itself, or it is never closed. -fn xml_child_body<'a>(xml: &'a str, name: &str) -> Option<&'a str> { - let mut at = 0; - // The elements open around `at`, innermost last: a closing tag must name the innermost one, - // or the markup is malformed and has no value. - let mut open_tags: Vec<&str> = Vec::new(); - // Where the wanted element's content starts, once its opening tag is seen at the top level. - let mut open = None; - loop { - let tag = next_tag(xml, at)?; - at = tag.end; - if tag.close { - if open_tags.pop()? != tag.local { - return None; - } - if open_tags.is_empty() - && let Some(start) = open - { - return xml.get(start..tag.start); - } - } else if !tag.empty { - if open_tags.is_empty() && tag.local == name { - open = Some(tag.end); - } - open_tags.push(tag.local); - } else if open_tags.is_empty() && tag.local == name { - return None; - } - } -} - -/// The trimmed text at `path` (element names from the outside in, any prefix, each a child of the -/// one before) in `xml`, if it is plain text. -fn xml_path_text(xml: &str, path: &[&str]) -> Option { - let body = path.iter().try_fold(xml, |within, name| xml_child_body(within, name))?; - let text = body.trim(); - (!text.is_empty() && !text.contains('<')).then(|| text.to_owned()) -} - -/// `dynamicRender` at `config/acrobat/acrobat7` in the configuration packet: the `config` child -/// of the `xdp` root in a whole-XDP stream, or the `config` packet itself. The same names inside -/// the form data or the template are not the configuration. -fn xfa_config_dynamic_render(packet: &str) -> Option { - const IN_CONFIG: [&str; 4] = ["config", "acrobat", "acrobat7", "dynamicRender"]; - xml_path_text(packet, &["xdp", IN_CONFIG[0], IN_CONFIG[1], IN_CONFIG[2], IN_CONFIG[3]]).or_else(|| xml_path_text(packet, &IN_CONFIG)) -} - -#[cfg(test)] -mod tests { - #[test] - fn png_predictor_rows_longer_than_the_data_are_refused_before_allocating() { - // Vendored lopdf patch: a fuzzed `/Columns 4294967295` allocated two 4 GiB rows before - // reading any data. A row that cannot fit in the data is refused up front. - let e = lopdf::filters::png::decode_frame(&[2, 0, 0, 0], 1, 64 << 20).unwrap_err(); - assert!(e.to_string().contains("longer than the data"), "{e}"); - // A real frame still decodes: two 3-byte rows, the second Up-filtered. - assert_eq!(lopdf::filters::png::decode_frame(&[0, 1, 2, 3, 2, 1, 1, 1], 1, 3).unwrap(), [1, 2, 3, 2, 3, 4]); - } - - #[test] - fn empty_png_predictor_frames_do_not_allocate_rows() { - // usize::MAX cannot be reserved, so the old code safely errors before allocating. - // An empty frame needs no rows regardless of the declared width. - assert!(lopdf::filters::png::decode_frame(&[], 1, usize::MAX).unwrap().is_empty()); - } - - fn tiff_predictor_stream(data: Vec, columns: i64, colors: i64, bits: i64) -> lopdf::Stream { - let mut params = lopdf::Dictionary::new(); - params.set("Predictor", 2i64); - params.set("Columns", columns); - params.set("Colors", colors); - params.set("BitsPerComponent", bits); - let mut dict = lopdf::Dictionary::new(); - dict.set("DecodeParms", params); - let mut stream = lopdf::Stream::new(dict, data); - stream.compress().unwrap(); - assert_eq!(stream.dict.get(b"Filter").unwrap().as_name().unwrap(), b"FlateDecode"); - stream - } - - #[test] - fn tiff_subbyte_predictor_row_width_overflow_is_refused() { - // Each multiplication overflows before any scratch allocation in the old debug build. - for bits in [1, 2, 4] { - let stream = tiff_predictor_stream(vec![0; 64], i64::MAX, 3, bits); - assert!( - matches!(stream.decompressed_content_with_limit(64), Err(lopdf::Error::Decompress(lopdf::DecompressError::Predictor(_)))), - "{bits}-bit row" - ); - } - } - - #[test] - fn tiff_subbyte_predictor_scratch_is_bounded_by_available_samples() { - // 1/2-bit row widths fit usize on 32- and 64-bit hosts; Colors previously made Vec - // reject the capacity before allocating. A partial row has no preceding pixel. - for bits in [1, 2] { - let data = vec![0b1010_0110; 64]; - let stream = tiff_predictor_stream(data.clone(), 1, i64::try_from(isize::MAX).unwrap(), bits); - assert_eq!(stream.decompressed_content_with_limit(64).unwrap(), data, "{bits}-bit row"); - } - } - - #[test] - fn tiff_subbyte_predictors_keep_components_rows_and_padding() { - // Repeated tiny rows compress through the public Stream API. Rows remain independent, - // differences wrap at each component depth, and trailing padding bits survive. - for (columns, colors, bits, encoded, decoded) in [ - (16, 1, 1, vec![255, 170, 8, 255], vec![170, 204, 15, 85]), - (6, 1, 2, vec![85, 179], vec![108, 147]), - (3, 1, 4, vec![25, 16], vec![26, 176]), - (2, 2, 4, vec![18, 34], vec![18, 52]), - ] { - let stream = tiff_predictor_stream(encoded.repeat(32), columns, colors, bits); - assert_eq!(stream.decompressed_content_with_limit(128).unwrap(), decoded.repeat(32), "{bits}-bit, {colors} colours"); - } - } - - // Xref-stream fixtures exercise indirect /Length lookup during document loading: stream 2's /Length is `3 0 R`, - // `containers` lists compressed (id, container, index) entries and `objects` the other objects in the file. - fn compressed_length_pdf(containers: &[(u32, u32, u16)], objects: &[(u32, &[u8])]) -> Vec { - let mut pdf = b"%PDF-1.7\n".to_vec(); - let ids = containers.iter().flat_map(|&(id, container, _)| [id, container]).chain(objects.iter().map(|&(id, _)| id)); - let xref_id = ids.max().unwrap_or(3) + 1; - let mut records = vec![[0u8; 7]; (xref_id + 1) as usize]; - records[0] = [0, 0, 0, 0, 0, 255, 255]; - let mut append = |id: u32, body: &[u8]| { - records[id as usize][0] = 1; - records[id as usize][1..5].copy_from_slice(&(pdf.len() as u32).to_be_bytes()); - pdf.extend_from_slice(format!("{id} 0 obj\n").as_bytes()); - pdf.extend_from_slice(body); - pdf.extend_from_slice(b"\nendobj\n"); - }; - append(1, b"<< /Type /Catalog >>"); - append(2, b"<< /Length 3 0 R >>\nstream\nhello\nendstream"); - for &(id, body) in objects { - append(id, body); - } - for &(id, container, index) in containers { - records[id as usize][0] = 2; - records[id as usize][1..5].copy_from_slice(&container.to_be_bytes()); - records[id as usize][5..7].copy_from_slice(&index.to_be_bytes()); - } - let xref_offset = pdf.len(); - records[xref_id as usize][0] = 1; - records[xref_id as usize][1..5].copy_from_slice(&(xref_offset as u32).to_be_bytes()); - pdf.extend_from_slice( - format!("{xref_id} 0 obj\n<< /Type /XRef /Size {} /Root 1 0 R /W [1 4 2] /Length {} >>\nstream\n", records.len(), records.len() * 7) - .as_bytes(), - ); - for record in records { - pdf.extend_from_slice(&record); - } - pdf.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{xref_offset}\n%%EOF\n").as_bytes()); - pdf - } - - // Stream 2's /Length starts a chain of `hops` indirect /Length lookups, each naming a new object; only the last - // link's /Length is direct. Compressed: link k is the integer 3 + 2k (the length it measures) alone in the object - // stream 4 + 2k, whose own /Length is link k + 1. Plain: link k is the stream 3 + k, whose /Length is link k + 1. - fn length_chain_pdf(hops: u32, compressed: bool) -> Vec { - let length = |hop: u32, next: u32, data: &str| if hop + 1 < hops { format!("{next} 0 R") } else { data.len().to_string() }; - let (mut containers, mut objects) = (Vec::new(), Vec::new()); - let mut value = 5; // the length of stream 2's data, "hello" - for hop in 0..hops { - if compressed { - let (id, container) = (3 + 2 * hop, 4 + 2 * hop); - let index = format!("{id} 0 "); - let data = format!("{index}{value}"); - let dict = format!("/Type /ObjStm /N 1 /First {} /Length {}", index.len(), length(hop, id + 2, &data)); - objects.push((container, format!("<< {dict} >>\nstream\n{data}\nendstream"))); - containers.push((id, container, 0)); - value = data.len(); - } else { - objects.push((3 + hop, format!("<< /Length {} >>\nstream\n\nendstream", length(hop, 4 + hop, "")))); - } - } - let objects: Vec<_> = objects.iter().map(|(id, body)| (*id, body.as_bytes())).collect(); - compressed_length_pdf(&containers, &objects) - } - - // Lenient loading keeps a document whose stream 2 has an unresolvable /Length: the catalog, and stream 2's - // dictionary without its data. - fn load_without_stream_2_data(pdf: &[u8]) -> Document { - let doc = Document::load_mem(pdf).unwrap(); - assert!(doc.catalog().unwrap().has_type(b"Catalog")); - assert!(doc.get_object((2, 0)).unwrap().as_stream().unwrap().content.is_empty()); - doc - } - - #[test] - fn compressed_object_self_container_does_not_recurse() { - let doc = load_without_stream_2_data(&compressed_length_pdf(&[(3, 3, 0)], &[])); - assert!(doc.get_object((3, 0)).is_err()); - } - - #[test] - fn compressed_object_mutual_containers_do_not_recurse() { - let doc = load_without_stream_2_data(&compressed_length_pdf(&[(3, 4, 0), (4, 3, 0)], &[])); - assert!(doc.get_object((3, 0)).is_err()); - assert!(doc.get_object((4, 0)).is_err()); - } - - #[test] - fn object_stream_length_cycle_does_not_recurse() { - let pdf = compressed_length_pdf(&[(3, 4, 0)], &[(4, b"<< /Type /ObjStm /N 1 /First 4 /Length 3 0 R >>\nstream\n3 0 5\nendstream")]); - let doc = load_without_stream_2_data(&pdf); - // The container's /Length is the object it holds: the container is kept without data, so 3 never loads. - assert!(doc.get_object((4, 0)).unwrap().as_stream().unwrap().content.is_empty()); - assert!(doc.get_object((3, 0)).is_err()); - } - - #[test] - fn compressed_object_container_chain_does_not_recurse() { - let containers: Vec<_> = (3..100_003).map(|id| (id, id + 1, 0)).collect(); - let pdf = compressed_length_pdf(&containers, &[(100_003, b"<< /Type /ObjStm /N 1 /First 4 /Length 5 >>\nstream\n3 0 5\nendstream")]); - let doc = load_without_stream_2_data(&pdf); - // The object stream in the file loads, but the xref puts 3 in the compressed container 4, which is refused. - assert_eq!(doc.get_object((100_003, 0)).unwrap().as_stream().unwrap().content, b"3 0 5"); - assert!(doc.get_object((3, 0)).is_err()); - } - - #[test] - fn compressed_objects_and_indirect_stream_length_still_load() { - let pdf = compressed_length_pdf( - &[(3, 4, 0), (5, 4, 1)], - &[(4, b"<< /Type /ObjStm /N 2 /First 8 /Length 17 >>\nstream\n3 0 5 2 5 (value)\nendstream")], - ); - let doc = Document::load_mem_with_options(&pdf, LoadOptions { strict: true, ..Default::default() }).unwrap(); - assert_eq!(doc.get_object((3, 0)).unwrap().as_i64().unwrap(), 5); - assert_eq!(doc.get_object((5, 0)).unwrap().as_str().unwrap(), b"value"); - assert_eq!(doc.get_object((2, 0)).unwrap().as_stream().unwrap().content, b"hello"); - } - - #[test] - fn compressed_length_in_a_container_with_an_indirect_length_still_loads() { - // Two hops, as in a valid file: stream 2's /Length (3) is compressed in container 4, whose own /Length (5) is - // a plain indirect integer. - let pdf = - compressed_length_pdf(&[(3, 4, 0)], &[(4, b"<< /Type /ObjStm /N 1 /First 4 /Length 5 0 R >>\nstream\n3 0 5\nendstream"), (5, b"5")]); - let doc = Document::load_mem_with_options(&pdf, LoadOptions { strict: true, ..Default::default() }).unwrap(); - assert_eq!(doc.get_object((3, 0)).unwrap().as_i64().unwrap(), 5); - assert_eq!(doc.get_object((5, 0)).unwrap().as_i64().unwrap(), 5); - assert_eq!(doc.get_object((2, 0)).unwrap().as_stream().unwrap().content, b"hello"); - } - - #[test] - fn compressed_length_chain_does_not_recurse() { - // Every container is an ordinary object in the file and every hop names a new object, so neither the - // container check nor the cycle history stops the chain; each hop recursed once more (a 512 KiB stack - // overflowed within 150 hops). Lookups past the cap fail: the first link never loads, while the last one, - // next to the direct /Length, does. - let hops = 5_000; - let doc = load_without_stream_2_data(&length_chain_pdf(hops, true)); - assert!(doc.get_object((3, 0)).is_err()); - assert!(doc.get_object((3 + 2 * (hops - 1), 0)).unwrap().as_i64().is_ok()); - // The cap itself: loading link j reads its container's /Length, a chain of 2 * (hops - 1 - j) lookups, and - // 16 are allowed, so the 9th link from the end loads and the 10th does not. - assert!(doc.get_object((3 + 2 * (hops - 9), 0)).is_ok()); - assert!(doc.get_object((3 + 2 * (hops - 10), 0)).is_err()); - } - - #[test] - fn plain_stream_length_chain_does_not_recurse() { - // The same recursion without object streams: each stream's /Length names the next stream (a 512 KiB stack - // overflowed within 200 hops). A stream is no length, so every link is kept as a stream without data. - let hops = 5_000; - let doc = load_without_stream_2_data(&length_chain_pdf(hops, false)); - assert!((3..3 + hops).all(|id| doc.get_object((id, 0)).is_ok_and(|link| link.as_stream().is_ok_and(|s| s.content.is_empty())))); - } - - fn indexed_object_stream(pairs: &[(u32, usize)], members: &[u8]) -> lopdf::Stream { - let mut content = Vec::new(); - for &(id, offset) in pairs { - content.extend_from_slice(format!("{id} {offset} ").as_bytes()); - } - let mut dict = Dictionary::new(); - dict.set("Type", "ObjStm"); - dict.set("N", pairs.len() as i64); - dict.set("First", content.len() as i64); - content.extend_from_slice(members); - lopdf::Stream::new(dict, content) - } - - #[test] - fn object_stream_member_extents_bound_owned_strings() { - let mut members = vec![b'('; 32]; - members.extend(vec![b'x'; 1 << 20]); - members.extend([b')'; 32]); - // Put the innermost (complete) string first in index order, then overlapping starts - // and duplicates. Only this first member at offset 31 may own the large string. - let mut pairs = vec![(100, 31)]; - pairs.extend((0..31).map(|offset| (offset as u32 + 1, offset))); - pairs.extend([(200, 31), (201, 31)]); - let stream = indexed_object_stream(&pairs, &members); - let parsed = lopdf::ObjectStream::new_with_limit(&stream, Some(2 << 20)).unwrap(); - assert_eq!(parsed.objects.get(&(100, 0)).unwrap().as_str().unwrap().len(), 1 << 20); - let total: usize = parsed.objects.values().filter_map(|object| object.as_str().ok()).map(<[u8]>::len).sum(); - assert!(total <= stream.content.len(), "owned {total} string bytes from {} decoded bytes", stream.content.len()); - assert!(!parsed.objects.contains_key(&(200, 0))); - assert!(!parsed.objects.contains_key(&(201, 0))); - } - - #[test] - fn object_stream_member_extents_preserve_index_order_and_whitespace() { - let members = b" \n42\n /Name \n[true 7]\n << /Key (value) >>\n (last)"; - // Unsorted offsets, and object 9 appears twice: its last index entry still wins. - let pairs = [(9, 0), (12, 21), (10, 5), (11, 12), (9, 41)]; - let parsed = lopdf::ObjectStream::new(&indexed_object_stream(&pairs, members)).unwrap(); - assert_eq!(parsed.objects.len(), 4); - assert_eq!(parsed.objects.get(&(9, 0)).unwrap().as_str().unwrap(), b"last"); - assert_eq!(parsed.objects.get(&(10, 0)).unwrap().as_name().unwrap(), b"Name"); - assert_eq!(parsed.objects.get(&(11, 0)).unwrap().as_array().unwrap(), &[Object::Boolean(true), Object::Integer(7)]); - assert_eq!(parsed.objects.get(&(12, 0)).unwrap().as_dict().unwrap().get(b"Key").unwrap().as_str().unwrap(), b"value"); - } - - #[test] - fn view_and_user_space_round_trip_for_every_rotation() { - for rotation in [0u16, 90, 180, 270] { - let (w, h) = if rotation % 180 == 0 { (200.0, 300.0) } else { (300.0, 200.0) }; - let p = super::PageInfo { width: w, height: h, label: String::new(), crop: [10.0, 20.0, 210.0, 320.0], rotation }; - for (x, y) in [(0.0, 0.0), (15.0, 40.0), (w, h)] { - let [ux, uy] = p.view_to_user(x, y); - let [vx, vy] = p.user_to_view(ux, uy); - assert!((vx - x).abs() < 1e-3 && (vy - y).abs() < 1e-3, "{rotation}: {x},{y} → {ux},{uy} → {vx},{vy}"); - } - } - // Unrotated: the view's top-left is the crop box's top-left. - let p = super::PageInfo { width: 200.0, height: 300.0, label: String::new(), crop: [10.0, 20.0, 210.0, 320.0], rotation: 0 }; - assert_eq!(p.view_to_user(0.0, 0.0), [10.0, 320.0]); - assert_eq!(p.view_rect_to_quad([0.0, 0.0, 10.0, 5.0]), [10.0, 320.0, 20.0, 320.0, 10.0, 315.0, 20.0, 315.0]); - } - - /// #2583: a NoRotate comment is drawn upright with its top-left as displayed at the upper-left - /// corner of `/Rect`, its width across and its height down; a box resized as drawn maps back to - /// the `/Rect` that shows it. Other comments, and pages that aren't quarter-turned, keep `/Rect`. - #[test] - fn no_rotate_comments_are_shown_upright_from_their_corner_and_back() { - let mut note = super::Annotation { - page: 0, - subtype: "Text".into(), - author: None, - contents: None, - modified: None, - name: None, - in_reply_to: None, - parent: None, - grouped: false, - rect: [40.0, 50.0, 70.0, 70.0], - color: None, - index: 0, - state: None, - quads: Vec::new(), - locked: false, - intent: None, - fill_sign: false, - no_rotate: true, - points: Vec::new(), - }; - let rect = note.rect.map(f64::from); - for rotation in [0u16, 90, 180, 270, 45] { - let (w, h) = if rotation % 180 == 90 { (140.0, 200.0) } else { (200.0, 140.0) }; - let p = super::PageInfo { width: w, height: h, label: String::new(), crop: [0.0, 0.0, 200.0, 140.0], rotation }; - let shown = note.shown_rect(rotation); - let (a, b) = (p.user_to_view(shown[0], shown[1]), p.user_to_view(shown[2], shown[3])); - let [cx, cy] = p.user_to_view(40.0, 70.0); - let want = [cx, cy, cx + 30.0, cy + 20.0]; - let got = [a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])]; - assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() < 1e-3), "{rotation}: shown {got:?}, want {want:?}"); - assert_eq!(note.rect_for_shown(note.shown(rect, rotation), rotation), rect, "{rotation}"); - // Moved by (5, -7) and resized to 50 × 10 as drawn, back to a /Rect. - let resized = [shown[0] as f64 + 5.0, shown[1] as f64 - 7.0, 0.0, 0.0]; - let across = if rotation % 180 == 90 { [10.0, 50.0] } else { [50.0, 10.0] }; - let resized = [resized[0], resized[1], resized[0] + across[0], resized[1] + across[1]]; - let back = note.rect_for_shown(resized, rotation); - assert_eq!((back[2] - back[0], back[3] - back[1]), (50.0, 10.0), "{rotation}: {back:?}"); - assert_eq!(note.shown(back, rotation), resized, "{rotation}"); - } - note.no_rotate = false; - assert_eq!(note.shown_rect(90), note.rect); - assert_eq!(note.rect_for_shown([1.0, 2.0, 3.0, 4.0], 90), [1.0, 2.0, 3.0, 4.0]); - } - - use super::*; - - #[test] - fn roman_and_alpha_labels() { - assert_eq!(roman(4, MAX_LABEL_BYTES).as_deref(), Some("iv")); - assert_eq!(roman(1994, MAX_LABEL_BYTES).as_deref(), Some("mcmxciv")); - assert_eq!(alpha(1, MAX_LABEL_BYTES).as_deref(), Some("a")); - assert_eq!(alpha(27, MAX_LABEL_BYTES).as_deref(), Some("aa")); - assert_eq!(alpha(53, MAX_LABEL_BYTES).as_deref(), Some("aaa")); - } - - // Synthetic label-only inspection fixtures: never construct an explosive branching tree. - fn label_info(nums: Vec, kids: Vec) -> DocInfo { - let mut doc = Document::with_version("1.7"); - let mut tree = Dictionary::new(); - tree.set("Nums", nums); - tree.set("Kids", kids); - doc.objects.insert((6, 0), Object::Dictionary(tree)); - let mut catalog = Dictionary::new(); - catalog.set("Type", Object::Name(b"Catalog".to_vec())); - catalog.set("PageLabels", Object::Reference((6, 0))); - doc.objects.insert((1, 0), Object::Dictionary(catalog)); - doc.trailer.set("Root", Object::Reference((1, 0))); - let mut info = DocInfo { - pages: (1..=3) - .map(|n| PageInfo { width: 200.0, height: 300.0, crop: [0.0, 0.0, 200.0, 300.0], rotation: 0, label: n.to_string() }) - .collect(), - ..Default::default() - }; - Inspector::new(&doc).fill(&mut info); - info - } - - fn label_spec(style: &str, prefix: Vec, first: i64) -> Object { - let mut d = Dictionary::new(); - d.set("S", Object::Name(style.as_bytes().to_vec())); - d.set("P", Object::String(prefix, lopdf::StringFormat::Hexadecimal)); - d.set("St", first); - Object::Dictionary(d) - } - - fn assert_label_fallback(info: &DocInfo) { - assert_eq!(info.pages.iter().map(|p| p.label.as_str()).collect::>(), ["1", "2", "3"]); - assert_eq!(info.warnings.len(), 1, "{:?}", info.warnings); - assert!(info.warnings[0].contains("Page labels"), "{:?}", info.warnings); - assert!(info.warnings[0].contains("physical page numbers"), "{:?}", info.warnings); - } - - // Contributor-original, blank three-page PDFs. The public importer reads both labels and metadata. - fn page_prefix_space_pdf(prefix: Vec, decimal: bool, indirect: bool) -> Vec { - use lopdf::dictionary; - let mut doc = Document::with_version("1.7"); - let pages_id = doc.new_object_id(); - let kids: Vec = (0..3) - .map(|_| { - doc.add_object(dictionary! { - "Type" => "Page", - "Parent" => pages_id, - "MediaBox" => vec![0.into(), 0.into(), 200.into(), 300.into()], - "Resources" => Dictionary::new(), - }) - .into() - }) - .collect(); - doc.objects.insert(pages_id, dictionary! { "Type" => "Pages", "Kids" => kids, "Count" => 3 }.into()); - let prefix = Object::String(prefix, lopdf::StringFormat::Hexadecimal); - let prefix = if indirect { Object::Reference(doc.add_object(prefix)) } else { prefix }; - let mut label = Dictionary::new(); - label.set("P", prefix); - if decimal { - label.set("S", Object::Name(b"D".to_vec())); - } - let labels = dictionary! { "Nums" => vec![Object::Integer(0), Object::Dictionary(label)] }; - let catalog_id = doc.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages_id, "PageLabels" => labels }); - let info_id = doc.add_object(dictionary! { "Title" => Object::string_literal(" Metadata title ") }); - doc.trailer.set("Root", catalog_id); - doc.trailer.set("Info", info_id); - let mut bytes = Vec::new(); - doc.save_to(&mut bytes).expect("write original page-prefix fixture"); - bytes - } - - #[test] - fn page_label_prefix_spaces_survive_public_inspection() { - let mut utf16 = vec![0xfe, 0xff]; - utf16.extend(" Ω ".encode_utf16().flat_map(u16::to_be_bytes)); - for (raw, prefix, decimal, indirect) in [ - (b" leading".to_vec(), " leading", true, false), - (b"trailing ".to_vec(), "trailing ", true, false), - (b" both ".to_vec(), " both ", false, false), - (b" ".to_vec(), " ", false, false), - (b" Caf\xe9 ".to_vec(), " Café ", true, false), - (utf16.clone(), " Ω ", true, false), - (utf16, " Ω ", false, true), - (Vec::new(), "", true, false), - (Vec::new(), "", false, false), - ] { - let bytes = page_prefix_space_pdf(raw, decimal, indirect); - let info = inspect(Arc::new(bytes), None).expect("public import of original page-prefix fixture"); - let expected: Vec = (1..=3) - .map(|n| { - if decimal { - format!("{prefix}{n}") - } else if prefix.is_empty() { - n.to_string() - } else { - prefix.to_owned() - } - }) - .collect(); - assert_eq!( - info.pages.iter().map(|p| p.label.clone()).collect::>(), - expected, - "{prefix:?}, decimal={decimal}, indirect={indirect}" - ); - assert!(info.warnings.is_empty(), "{:?}", info.warnings); - assert_eq!(info.title.as_deref(), Some("Metadata title"), "page-prefix decoding must not change metadata normalization"); - } - } - - #[test] - fn page_label_limits_keep_normal_pdf_numbering() { - for (style, first, expected) in [ - ("r", 4, ["iv", "v", "vi"]), - ("R", 1994, ["MCMXCIV", "MCMXCV", "MCMXCVI"]), - ("a", 26, ["z", "aa", "bb"]), - ("A", 52, ["ZZ", "AAA", "BBB"]), - ] { - let info = label_info(vec![Object::Integer(0), label_spec(style, Vec::new(), first)], Vec::new()); - assert_eq!(info.pages.iter().map(|p| p.label.as_str()).collect::>(), expected); - assert!(info.warnings.is_empty(), "{:?}", info.warnings); - } - } - - #[test] - fn page_label_limits_reject_long_prefixes_without_partial_labels() { - for prefix in [vec![b'x'; 1025], vec![b'x'; 2051]] { - let nums = vec![Object::Integer(0), label_spec("D", b"ok-".to_vec(), 1), Object::Integer(1), label_spec("D", prefix, 1)]; - assert_label_fallback(&label_info(nums, Vec::new())); - } - } - - #[test] - fn page_label_limits_count_decoded_utf8_bytes() { - let mut prefix = vec![0xfe, 0xff]; - for _ in 0..342 { - prefix.extend_from_slice(&0x2022u16.to_be_bytes()); - } - assert_label_fallback(&label_info(vec![Object::Integer(0), label_spec("", prefix, 1)], Vec::new())); - } - - #[test] - fn page_label_limits_bound_roman_and_alpha_output() { - // Just 1025 output bytes on the old path, so RED does not consume excessive memory. - for (style, first) in [("r", 1_025_000), ("a", 26 * 1024 + 1)] { - assert_label_fallback(&label_info(vec![Object::Integer(0), label_spec(style, Vec::new(), first)], Vec::new())); - } - } - - #[test] - fn page_label_limits_detect_one_node_cycle() { - let nums = vec![Object::Integer(0), label_spec("D", b"custom-".to_vec(), 1)]; - assert_label_fallback(&label_info(nums, vec![Object::Reference((6, 0))])); - } - - #[test] - fn page_label_limits_accept_utf16_ascii_at_the_output_boundary() { - let mut prefix = vec![0xfe, 0xff]; - for _ in 0..1023 { - prefix.extend_from_slice(&u16::from(b'x').to_be_bytes()); - } - let info = label_info(vec![Object::Integer(0), label_spec("D", prefix, 1)], Vec::new()); - assert!(info.warnings.is_empty(), "{:?}", info.warnings); - assert_eq!(info.pages[0].label, format!("{}1", "x".repeat(1023))); - assert!(info.pages.iter().all(|p| p.label.len() == MAX_LABEL_BYTES)); - } - - #[test] - fn page_label_limits_refuse_hostile_numbers_before_allocating() { - for style in ["r", "R", "a", "A"] { - assert_label_fallback(&label_info(vec![Object::Integer(0), label_spec(style, Vec::new(), i64::MAX)], Vec::new())); - } - // Decimal labels remain small even when their first number exceeds 32-bit usize. - let info = label_info(vec![Object::Integer(0), label_spec("D", Vec::new(), i64::MAX)], Vec::new()); - assert_eq!(info.pages[0].label, i64::MAX.to_string()); - assert_eq!(info.pages[1].label, (i64::MAX as u64 + 1).to_string()); - assert!(info.warnings.is_empty()); - } - - #[test] - fn page_label_limits_bound_wide_number_trees() { - assert_label_fallback(&label_info(Vec::new(), vec![Object::Null; MAX_LABEL_TREE_WORK + 1])); - let pair = [Object::Integer(0), label_spec("D", Vec::new(), 1)]; - let nums = (0..MAX_LABEL_TREE_WORK).flat_map(|_| pair.clone()).collect(); - assert_label_fallback(&label_info(nums, Vec::new())); - } - - #[test] - fn page_label_limits_bound_total_custom_output_atomically() { - let doc = Document::with_version("1.7"); - let mut tree = Dictionary::new(); - tree.set("Nums", vec![Object::Integer(0), label_spec("", vec![b'x'; MAX_LABEL_BYTES], 1)]); - let mut catalog = Dictionary::new(); - catalog.set("PageLabels", Object::Dictionary(tree)); - let count = MAX_LABEL_TOTAL_BYTES / MAX_LABEL_BYTES + 1; - let mut pages: Vec<_> = (1..=count) - .map(|n| PageInfo { width: 200.0, height: 300.0, crop: [0.0, 0.0, 200.0, 300.0], rotation: 0, label: n.to_string() }) - .collect(); - let err = Inspector::new(&doc).page_labels(&catalog, &mut pages).unwrap_err(); - assert!(err.contains("4 MiB"), "{err}"); - assert!(pages.iter().enumerate().all(|(i, p)| p.label == (i + 1).to_string())); - // Exact aggregate boundary remains supported. - pages.pop(); - Inspector::new(&doc).page_labels(&catalog, &mut pages).unwrap(); - assert!(pages.iter().all(|p| p.label.len() == MAX_LABEL_BYTES)); - } - - #[test] - fn page_label_limits_bound_the_total_of_decoded_prefixes() { - // `ranges` ranges naming one shared prefix object of `prefix` bytes, without a style. - let shared = |ranges: i64, prefix: usize| { - let mut doc = Document::with_version("1.7"); - doc.objects.insert((7, 0), Object::String(vec![b'x'; prefix], lopdf::StringFormat::Literal)); - let mut spec = Dictionary::new(); - spec.set("P", Object::Reference((7, 0))); - doc.objects.insert((8, 0), Object::Dictionary(spec)); - let mut tree = Dictionary::new(); - tree.set("Nums", (0..ranges).flat_map(|i| [Object::Integer(i), Object::Reference((8, 0))]).collect::>()); - let mut catalog = Dictionary::new(); - catalog.set("PageLabels", Object::Dictionary(tree)); - (doc, catalog) - }; - let mut pages: Vec<_> = - (1..=3).map(|n| PageInfo { width: 200.0, height: 300.0, crop: [0.0, 0.0, 200.0, 300.0], rotation: 0, label: n.to_string() }).collect(); - // Each range decodes its prefix, so a small file sharing one prefix object asks for a - // copy per range: 5,000 ranges and a 1,000-byte prefix make 5 MB, past the 4 MiB total. - // Three pages make three labels, far below the label total: the prefixes alone refuse it. - let (doc, catalog) = shared(5000, 1000); - let err = Inspector::new(&doc).page_labels(&catalog, &mut pages).unwrap_err(); - assert!(err.contains("prefixes exceed 4 MiB"), "{err}"); - assert!(pages.iter().enumerate().all(|(i, p)| p.label == (i + 1).to_string())); - // Exactly 4 MiB of prefixes is still read. - let (doc, catalog) = shared((MAX_PREFIX_TOTAL_BYTES / MAX_LABEL_BYTES) as i64, MAX_LABEL_BYTES); - Inspector::new(&doc).page_labels(&catalog, &mut pages).unwrap(); - assert!(pages.iter().all(|p| p.label.len() == MAX_LABEL_BYTES)); - } - - #[test] - fn page_label_limits_handle_prefix_only_and_reference_cycles() { - let info = label_info(vec![Object::Integer(0), label_spec("", b"prefix".to_vec(), i64::MAX)], Vec::new()); - assert!(info.warnings.is_empty()); - assert!(info.pages.iter().all(|p| p.label == "prefix")); - let mut doc = Document::with_version("1.7"); - doc.objects.insert((6, 0), Object::Reference((6, 0))); - let mut catalog = Dictionary::new(); - catalog.set("PageLabels", Object::Reference((6, 0))); - assert!(Inspector::new(&doc).page_labels(&catalog, &mut []).unwrap_err().contains("cyclic")); - } - - #[test] - fn page_label_limits_bound_depth_and_count_combined_work() { - for depth in [MAX_LABEL_TREE_DEPTH, MAX_LABEL_TREE_DEPTH + 1] { - let mut child = Dictionary::new(); - child.set("Nums", vec![Object::Integer(0), label_spec("D", b"custom-".to_vec(), 1)]); - let mut child = Object::Dictionary(child); - for _ in 1..depth { - let mut parent = Dictionary::new(); - parent.set("Kids", vec![child]); - child = Object::Dictionary(parent); - } - let info = label_info(Vec::new(), vec![child]); - if depth == MAX_LABEL_TREE_DEPTH { - assert!(info.warnings.is_empty()); - assert_eq!(info.pages[0].label, "custom-1"); - } else { - assert_label_fallback(&info); - } - } - // Each array alone fits: their combined work still must be bounded. - let half = MAX_LABEL_TREE_WORK / 2; - let spec = label_spec("D", Vec::new(), 1); - let nums = (0..half).flat_map(|i| [Object::Integer(i as i64), spec.clone()]).collect(); - assert_label_fallback(&label_info(nums, vec![Object::Null; half])); - } - - #[test] - fn page_label_limits_keep_the_later_of_duplicate_ranges() { - let nums = vec![Object::Integer(0), label_spec("D", b"first-".to_vec(), 1), Object::Integer(0), label_spec("D", b"last-".to_vec(), 1)]; - let info = label_info(nums, Vec::new()); - assert_eq!(info.pages.iter().map(|p| p.label.as_str()).collect::>(), ["last-1", "last-2", "last-3"]); - assert_eq!(info.warnings.len(), 1, "{:?}", info.warnings); - assert!(info.warnings[0].contains("twice"), "{:?}", info.warnings); - } - - #[test] - fn page_label_limits_keep_other_ranges_when_one_is_too_long() { - // Page 1 is labelled normally; pages 2–3 would need over 1024 bytes of roman numerals. - let nums = vec![Object::Integer(0), label_spec("D", b"ok-".to_vec(), 1), Object::Integer(1), label_spec("r", Vec::new(), 1_025_000)]; - let info = label_info(nums, Vec::new()); - assert_eq!(info.pages.iter().map(|p| p.label.as_str()).collect::>(), ["ok-1", "2", "3"]); - assert_eq!(info.warnings.len(), 1, "{:?}", info.warnings); - assert!(info.warnings[0].contains("physical page numbers"), "{:?}", info.warnings); - } - - const ATTACHMENTS: &[u8] = b"%PDF-1.7 -1 0 obj << /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles << /Names [(notes.txt) 6 0 R] >> >> >> endobj -2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj -3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [4 0 R] /Resources << /Font << /F1 9 0 R >> >> >> endobj -4 0 obj << /Type /Annot /Subtype /FileAttachment /Rect [10 10 30 30] /Contents (data.csv) /FS 8 0 R >> endobj -5 0 obj << /Type /EmbeddedFile /Length 11 >> stream -hello notes -endstream endobj -6 0 obj << /Type /Filespec /F (notes.txt) /UF (notes.txt) /EF << /F 5 0 R >> /Desc (Doc-level) >> endobj -7 0 obj << /Type /EmbeddedFile /Length 5 >> stream -a,b,c -endstream endobj -8 0 obj << /Type /Filespec /F (data.csv) /EF << /F 7 0 R >> >> endobj -9 0 obj << /Type /Font /Subtype /Type1 /BaseFont /ABCDEF+Helvetica >> endobj -trailer << /Root 1 0 R >> -%%EOF"; - - #[test] - fn attachments_of_both_kinds_are_listed_and_extracted() { - let bytes = Arc::new(ATTACHMENTS.to_vec()); - let info = inspect(bytes.clone(), None).expect("opens"); - let names: Vec<_> = info.attachments.iter().map(|a| a.name.as_str()).collect(); - assert!(names.contains(&"notes.txt") && names.contains(&"data.csv"), "{names:?}"); - for a in &info.attachments { - let data = attachment_data(&bytes, None, a).expect("extracts"); - let expected: &[u8] = if a.name == "notes.txt" { b"hello notes" } else { b"a,b,c" }; - assert_eq!(data, expected, "{}", a.name); - } - // A non-embedded /Helvetica is reported as standing in for itself: the standard-14 - // metrics are exact, so the substitution is named rather than left implicit. - assert_eq!( - info.fonts, - vec![FontInfo { - name: "Helvetica".into(), - kind: "Type1".into(), - embedded: false, - subset: true, - encoding: None, - substitute: Some("Helvetica".into()), - substitution: Some(pdfcraft_fonts::fallback::Why::NamedStandard14.note().into()), - }] - ); - } - - const LAYERS: &[u8] = b"%PDF-1.7 -1 0 obj << /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R 6 0 R 7 0 R] /D << /OFF [6 0 R] /RBGroups [[5 0 R 6 0 R] [7 0 R] 8 0 R] >> >> >> endobj -2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj -3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [10 0 R 11 0 R] >> endobj -5 0 obj << /Type /OCG /Name (Red) >> endobj -6 0 obj << /Type /OCG /Name (Green) >> endobj -7 0 obj << /Type /OCG /Name (Blue) >> endobj -8 0 obj [6 0 R 7 0 R] endobj -9 0 obj [/OFF 5 0 R] endobj -10 0 obj << /Type /Annot /Subtype /Link /Rect [10 10 50 30] /A << /S /SetOCGState /State [7 0 R /ON 6 0 R /Bogus 5 0 R /Toggle 5 0 R 1 7 0 R] /PreserveRB false >> >> endobj -11 0 obj << /Type /Annot /Subtype /Link /Rect [60 10 100 30] /A << /S /SetOCGState /State 9 0 R >> >> endobj -trailer << /Root 1 0 R >> -%%EOF"; - - /// `/Trans` and `/Dur` (ISO 32000-2 §12.4.4): read with the spec's defaults for what a page - /// leaves out, lenient about what is out of range, and only for pages that have either. - const TRANSITIONS: &[u8] = b"%PDF-1.7 -1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj -2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R 5 0 R 6 0 R 7 0 R] /Count 5 /MediaBox [0 0 200 200] >> endobj -3 0 obj << /Type /Page /Parent 2 0 R /Trans << /Type /Trans /S /Wipe /D 2.5 /Di 270 >> /Dur 5 >> endobj -4 0 obj << /Type /Page /Parent 2 0 R /Dur 3 >> endobj -5 0 obj << /Type /Page /Parent 2 0 R /Trans 8 0 R >> endobj -6 0 obj << /Type /Page /Parent 2 0 R /Trans << /S /Bogus /D (slow) /Di 45 /Dm /Q >> /Dur -1 >> endobj -7 0 obj << /Type /Page /Parent 2 0 R >> endobj -8 0 obj << /S /Fly /M /O /SS 0.5 /B true /Di /None /Dm /V >> endobj -trailer << /Root 1 0 R >> -%%EOF"; - - #[test] - fn page_transitions_and_display_times_are_read() { - use super::TransitionStyle as S; - let info = inspect(Arc::new(TRANSITIONS.to_vec()), None).expect("opens"); - let t = &info.transitions; - assert_eq!(t.iter().map(|t| t.page).collect::>(), [0, 1, 2, 3], "pages with /Trans or /Dur, in order; page 5 has neither"); - assert_eq!((t[0].style, t[0].duration, t[0].direction, t[0].display), (Some(S::Wipe), 2.5, Some(270), Some(5.0))); - assert!((t[0].horizontal, t[0].inward, t[0].scale, t[0].opaque) == (true, true, 1.0, false), "defaults: {:?}", t[0]); - assert_eq!((t[1].style, t[1].duration, t[1].display), (None, 1.0, Some(3.0)), "/Dur without /Trans"); - assert_eq!( - (t[2].style, t[2].inward, t[2].scale, t[2].opaque, t[2].direction, t[2].horizontal), - (Some(S::Fly), false, 0.5, true, None, false), - "an indirect /Trans: {:?}", - t[2] - ); - // Out of range: an unknown style is the spec's default R, a non-numeric /D the default - // second, a direction that is not one of the five falls back to 0, a bad /Dm to - // horizontal, and a negative /Dur is no display time. - assert_eq!( - (t[3].style, t[3].duration, t[3].direction, t[3].horizontal, t[3].display), - (Some(S::Replace), 1.0, Some(0), true, None), - "{:?}", - t[3] - ); - } - - const CUSTOM_INFO: &[u8] = b"%PDF-1.7 -1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj -2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >> endobj -3 0 obj << /Type /Page /Parent 2 0 R >> endobj -4 0 obj << /Title (Report) /Producer (Test) /Project (Apollo) /Revision 7 /Dept (R&D) /Trapped /False /Note >> endobj -trailer << /Root 1 0 R /Info 4 0 R >> -%%EOF"; - - #[test] - fn custom_document_properties_are_listed_apart_from_the_standard_ones() { - let info = inspect(Arc::new(CUSTOM_INFO.to_vec()), None).expect("opens"); - assert_eq!((info.title.as_deref(), info.producer.as_deref()), (Some("Report"), Some("Test"))); - let mut custom = info.custom.clone(); - custom.sort(); - // Revision is a number, not text, so it is not listed; Trapped is a standard entry. - assert_eq!( - custom, - [("Dept".to_string(), "R&D".to_string()), ("Note".to_string(), "部".to_string()), ("Project".to_string(), "Apollo".to_string())] - ); - } - - #[test] - fn radio_button_layer_groups_are_read() { - let info = inspect(Arc::new(LAYERS.to_vec()), None).expect("opens"); - let layers: Vec<_> = info.layers.iter().map(|l| (l.id, l.name.as_str(), l.visible)).collect(); - assert_eq!(layers, [((5, 0), "Red", true), ((6, 0), "Green", false), ((7, 0), "Blue", true)]); - // A group of one constrains nothing; an indirect group is read. - assert_eq!(info.layer_groups, [vec![(5, 0), (6, 0)], vec![(6, 0), (7, 0)]]); - } - - #[test] - fn initial_layer_visibility_is_the_default_configuration() { - // BaseState OFF: Red is listed ON; Green is listed both ON and OFF, and OFF wins; Blue - // isn't listed, so it takes the base state. - let pdf = b"%PDF-1.7 -1 0 obj << /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R 6 0 R 7 0 R] /D << /BaseState /OFF /ON [5 0 R 6 0 R] /OFF [6 0 R] >> >> >> endobj -2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj -3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] >> endobj -5 0 obj << /Type /OCG /Name (Red) >> endobj -6 0 obj << /Type /OCG /Name (Green) >> endobj -7 0 obj << /Type /OCG /Name (Blue) >> endobj -trailer << /Root 1 0 R >> -%%EOF"; - let info = inspect(Arc::new(pdf.to_vec()), None).expect("opens"); - let layers: Vec<_> = info.layers.iter().map(|l| (l.name.as_str(), l.initial)).collect(); - assert_eq!(layers, [("Red", true), ("Green", false), ("Blue", false)]); - } - - const ORPHAN_FIELDS: &[u8] = b"%PDF-1.7 -1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >> endobj -2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj -3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [5 0 R 6 0 R] >> endobj -4 0 obj << /Fields [] >> endobj -5 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /T (alpha) /V (one) /Rect [10 100 90 120] >> endobj -6 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /T (beta) /Rect [10 60 90 80] >> endobj -trailer << /Root 1 0 R >> -%%EOF"; - - #[test] - fn fields_listed_only_on_the_pages_are_listed() { - let info = inspect(Arc::new(ORPHAN_FIELDS.to_vec()), None).expect("opens"); - let listed: Vec<_> = info.fields.iter().map(|f| (f.name.as_str(), f.value.as_deref(), f.page)).collect(); - assert_eq!(listed, [("alpha", Some("one"), Some(0)), ("beta", None, Some(0))], "{:?}", info.fields); - } - - /// A `Structure` that counts the objects read through it. - struct CountingReads<'a> { - doc: &'a Document, - reads: std::cell::Cell, - } - - impl Structure for CountingReads<'_> { - fn trailer(&self) -> &Dictionary { - &self.doc.trailer - } - fn get_object(&self, id: ObjectId) -> lopdf::Result<&Object> { - self.reads.set(self.reads.get() + 1); - self.doc.get_object(id) - } - fn encrypted(&self) -> bool { - false - } - } - - /// One comment referenced many times, or a page-shared `/Annots` array, is listed once, and many - /// small comments sharing one large `/Contents` string stop at the retained-bytes budget. - #[test] - fn repeated_annotations_are_listed_once_within_a_byte_budget() { - let small = "<< /Subtype /Text /Rect [0 0 1 1] /Contents 7 0 R >> ".repeat(1200); - let pdf = format!( - "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ - 2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj\n\ - 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots 5 0 R >> endobj\n\ - 4 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots 5 0 R >> endobj\n\ - 5 0 obj [{} {small}] endobj\n\ - 6 0 obj << /Type /Annot /Subtype /Text /Rect [0 0 1 1] /Contents (note) >> endobj\n\ - 7 0 obj ({}) endobj\ntrailer << /Root 1 0 R >>\n%%EOF", - "6 0 R ".repeat(1000), - "x".repeat(64 << 10), - ); - let doc = Document::load_mem(pdf.as_bytes()).expect("loads"); - let page = PageInfo { width: 200.0, height: 200.0, crop: [0.0, 0.0, 200.0, 200.0], rotation: 0, label: String::new() }; - let mut info = DocInfo { pages: vec![page.clone(), page], ..Default::default() }; - Inspector::new(&doc).fill(&mut info); - let notes = &info.annotations; - assert_eq!(notes.iter().filter(|a| a.contents.as_deref() == Some("note")).count(), 1); - assert!(notes.iter().all(|a| a.page == 0), "the shared array is the first page's"); - assert!(notes.len() > 1); - assert!(notes.iter().map(retained).sum::() <= MAX_ANNOTATION_BYTES); - } - - /// A link reached through an indirect object that holds only a reference to it is the same - /// annotation as the link itself, and so is a page array reached the same way: each is listed once. - #[test] - fn an_annotation_reached_through_a_reference_chain_is_listed_once() { - let pdf = "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ - 2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj\n\ - 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots 7 0 R >> endobj\n\ - 4 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots 8 0 R >> endobj\n\ - 5 0 obj 6 0 R endobj\n\ - 6 0 obj << /Type /Annot /Subtype /Link /Rect [0 0 1 1] /A << /S /URI /URI (https://example.com) >> >> endobj\n\ - 7 0 obj [5 0 R 6 0 R] endobj\n\ - 8 0 obj 7 0 R endobj\n\ - trailer << /Root 1 0 R >>\n%%EOF"; - let mut doc = Document::load_mem(pdf.as_bytes()).expect("loads"); - let mut saved = Vec::new(); - doc.save_to(&mut saved).expect("saves"); - let lazy = LazyStructure::new(Arc::new(saved), None).expect("opens"); - let page = PageInfo { width: 200.0, height: 200.0, crop: [0.0, 0.0, 200.0, 200.0], rotation: 0, label: String::new() }; - for reader in [&doc as &dyn Structure, &lazy] { - let mut info = DocInfo { pages: vec![page.clone(), page.clone()], ..Default::default() }; - Inspector::new(reader).fill(&mut info); - assert_eq!(info.links.len(), 1, "{:?}", info.links); - } - } - - /// A reply finds its comment where the listing keeps it: on the first page of a page-shared - /// `/Annots` array, whether its `/IRT` names the comment or an object holding a reference to it. - #[test] - fn a_reply_finds_its_comment_through_a_reference_chain_and_a_shared_array() { - let pdf = "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ - 2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj\n\ - 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots 7 0 R >> endobj\n\ - 4 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots 7 0 R >> endobj\n\ - 5 0 obj 6 0 R endobj\n\ - 6 0 obj << /Type /Annot /Subtype /Text /Rect [0 0 1 1] /Contents (comment) >> endobj\n\ - 7 0 obj [5 0 R 8 0 R 9 0 R] endobj\n\ - 8 0 obj << /Type /Annot /Subtype /Text /Rect [0 0 1 1] /Contents (to the comment) /IRT 6 0 R >> endobj\n\ - 9 0 obj << /Type /Annot /Subtype /Text /Rect [0 0 1 1] /Contents (through the chain) /IRT 5 0 R >> endobj\n\ - trailer << /Root 1 0 R >>\n%%EOF"; - let mut doc = Document::load_mem(pdf.as_bytes()).expect("loads"); - let mut saved = Vec::new(); - doc.save_to(&mut saved).expect("saves"); - let lazy = LazyStructure::new(Arc::new(saved), None).expect("opens"); - let page = PageInfo { width: 200.0, height: 200.0, crop: [0.0, 0.0, 200.0, 200.0], rotation: 0, label: String::new() }; - for reader in [&doc as &dyn Structure, &lazy] { - let mut info = DocInfo { pages: vec![page.clone(), page.clone()], ..Default::default() }; - Inspector::new(reader).fill(&mut info); - let notes = &info.annotations; - assert_eq!(notes.len(), 3, "{notes:?}"); - let comment = notes.iter().find(|a| a.contents.as_deref() == Some("comment")).expect("listed"); - assert_eq!((comment.page, comment.index, comment.is_reply()), (0, 0, false)); - for reply in ["to the comment", "through the chain"] { - let a = notes.iter().find(|a| a.contents.as_deref() == Some(reply)).expect("listed"); - assert_eq!((a.page, a.parent), (0, Some((0, 0))), "{reply}"); - } - } - } - - /// A conversion rejected as too long, or trimmed short or to nothing, still spends the raw - /// length, so a string many entries share is converted only a few times. - #[test] - fn rejected_text_conversions_spend_the_budget() { - let mut budget = 10; - let mut conversions = 0; - for _ in 0..10 { - // Each 0xFF becomes the three-byte replacement character. - let kept = spend(&[0xFF; 5], &mut budget, |b| { - conversions += 1; - String::from_utf8_lossy(b).into_owned() - }); - assert_eq!(kept, None); - } - assert_eq!((conversions, budget), (2, 0)); - - let pdf = "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ - 2 0 obj << /Type /Pages /Kids [] /Count 0 >> endobj\n\ - trailer << /Root 1 0 R >>\n%%EOF"; - let doc = Document::load_mem(pdf.as_bytes()).expect("loads"); - let d = Dictionary::from_iter([("Contents", Object::string_literal(" "))]); - let mut budget = 10; - assert_eq!(Inspector::new(&doc).budgeted_text(&d, b"Contents", &mut budget), None); - assert_eq!(budget, 7); - // Text that trims short spends its raw length. - let d = Dictionary::from_iter([("Contents", Object::string_literal(" x"))]); - let mut budget = 10; - assert_eq!(Inspector::new(&doc).budgeted_text(&d, b"Contents", &mut budget).as_deref(), Some("x")); - assert_eq!(budget, 3); - } - - /// An annotation attachment's `/Contents` spends the text budget only when it names the - /// attachment, so long descriptions of named files leave a later web address listed. - #[test] - fn a_named_attachment_leaves_its_contents_unspent() { - let files = "<< /Subtype /FileAttachment /Rect [0 0 1 1] /Contents 6 0 R /FS << /F (a.txt) >> >> ".repeat(20); - // Longer than what 20 MiB of charged `/Contents` would leave of the 16 MiB budget. - let uri = format!("https://example.com/{}", "a".repeat(2 << 20)); - let pdf = format!( - "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ - 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ - 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [{files} \ - << /Subtype /Link /Rect [0 0 1 1] /A << /S /URI /URI ({uri}) >> >>] >> endobj\n\ - 6 0 obj ({}) endobj\n\ - trailer << /Root 1 0 R >>\n%%EOF", - "x".repeat(1 << 20), - ); - let doc = Document::load_mem(pdf.as_bytes()).expect("loads"); - let page = PageInfo { width: 200.0, height: 200.0, crop: [0.0, 0.0, 200.0, 200.0], rotation: 0, label: String::new() }; - let mut info = DocInfo { pages: vec![page], ..Default::default() }; - Inspector::new(&doc).fill(&mut info); - assert!(info.attachments.iter().all(|a| a.name == "a.txt")); - assert_eq!(info.links.len(), 1); - assert!(matches!(&info.links[0].target, LinkTarget::Uri(u) if *u == uri)); - } - - /// Embedded files whose name-tree keys share one long string keep it, as key and as name, only - /// within `MAX_LINK_ATTACHMENT_TEXT_BYTES`. - #[test] - fn embedded_file_keys_sharing_a_long_string_keep_it_within_a_budget() { - let entries = "4 0 R << /Type /Filespec >> ".repeat(20); - let pdf = format!( - "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles << /Names [{entries}] >> >> >> endobj\n\ - 2 0 obj << /Type /Pages /Kids [] /Count 0 >> endobj\n\ - 4 0 obj ({}) endobj\n\ - trailer << /Root 1 0 R >>\n%%EOF", - "x".repeat(1 << 20), - ); - let doc = Document::load_mem(pdf.as_bytes()).expect("loads"); - let mut info = DocInfo::default(); - Inspector::new(&doc).fill(&mut info); - let kept: usize = - info.attachments.iter().map(|a| a.name.len() + if let AttachmentSource::Document { key } = &a.source { key.len() } else { 0 }).sum(); - assert!(!info.attachments.is_empty()); - // Past the budget an attachment still has the generic name. - let generic = info.attachments.len() * "attachment".len(); - assert!(kept <= MAX_LINK_ATTACHMENT_TEXT_BYTES + generic, "{kept} bytes kept"); - } - - /// An embedded file the listing keeps is extracted, though earlier keys the listing could not - /// keep would fill a fresh key budget before it. - #[test] - fn a_listed_embedded_file_is_extracted_past_keys_the_listing_left_out() { - let skipped = "6 0 R 7 0 R ".repeat(9); - let pdf = format!( - "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles << /Names [{skipped}(b.txt) 9 0 R] >> >> >> endobj\n\ - 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ - 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [\ - << /Subtype /Link /Rect [0 0 1 1] /A << /S /URI /URI ({}) >> >>] >> endobj\n\ - 6 0 obj ({}) endobj\n\ - 7 0 obj << /Type /Filespec /F (a.txt) >> endobj\n\ - 8 0 obj << /Type /EmbeddedFile /Length 5 >> stream\nhello\nendstream endobj\n\ - 9 0 obj << /Type /Filespec /F (b.txt) /EF << /F 8 0 R >> >> endobj\n\ - trailer << /Root 1 0 R >>\n%%EOF", - "u".repeat(15 << 20), - "k".repeat(2 << 20), - ); - let bytes = Arc::new(pdf.into_bytes()); - let info = inspect(bytes.clone(), None).expect("opens"); - let listed = info.attachments.iter().find(|a| a.name == "b.txt").expect("b.txt is listed"); - assert_eq!(attachment_data(&bytes, None, listed).as_deref(), Ok(&b"hello"[..])); - } - - /// Links and attachments sharing one long string each keep a copy only within - /// `MAX_LINK_ATTACHMENT_TEXT_BYTES`, and every one is still listed. - #[test] - fn links_and_attachments_sharing_a_long_string_keep_it_within_a_budget() { - const EACH: usize = 20; - let links = "<< /Subtype /Link /Rect [0 0 1 1] /A 5 0 R >> ".repeat(EACH); - let files = "<< /Subtype /FileAttachment /Rect [0 0 1 1] /FS 7 0 R >> ".repeat(EACH); - let pdf = format!( - "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ - 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ - 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [{links} {files}] >> endobj\n\ - 5 0 obj << /S /URI /URI 6 0 R >> endobj\n\ - 6 0 obj ({}) endobj\n\ - 7 0 obj << /Type /Filespec /F (a.txt) /Desc 6 0 R >> endobj\n\ - trailer << /Root 1 0 R >>\n%%EOF", - "x".repeat(1 << 20), - ); - let doc = Document::load_mem(pdf.as_bytes()).expect("loads"); - let page = PageInfo { width: 200.0, height: 200.0, crop: [0.0, 0.0, 200.0, 200.0], rotation: 0, label: String::new() }; - let mut info = DocInfo { pages: vec![page], ..Default::default() }; - Inspector::new(&doc).fill(&mut info); - assert_eq!(info.links.len(), EACH); - assert_eq!(info.attachments.len(), EACH); - let uris = info.links.iter().map(|l| if let LinkTarget::Uri(u) = &l.target { u.len() } else { 0 }); - let descriptions = info.attachments.iter().map(|a| a.description.as_ref().map_or(0, String::len)); - let kept: usize = uris.chain(descriptions).sum(); - assert!(kept > 0 && kept <= MAX_LINK_ATTACHMENT_TEXT_BYTES, "{kept} bytes kept"); - assert!(info.links.iter().any(|l| matches!(&l.target, LinkTarget::Other(s) if s == "URI"))); - } - - /// Links and attachments after the comment budget fills are still listed, and a JavaScript link - /// among them still marks the document. - #[test] - fn links_and_attachments_past_the_comment_budget_are_listed() { - let small = "<< /Subtype /Text /Rect [0 0 1 1] /Contents 6 0 R >> ".repeat(1100); - let pdf = format!( - "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ - 2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj\n\ - 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [{small} \ - << /Subtype /Link /Rect [0 0 1 1] /A << /S /JavaScript /JS (1) >> >>] >> endobj\n\ - 4 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [\ - << /Subtype /FileAttachment /Rect [0 0 1 1] /FS << /Type /Filespec /F (a.txt) >> >>] >> endobj\n\ - 6 0 obj ({}) endobj\ntrailer << /Root 1 0 R >>\n%%EOF", - "x".repeat(64 << 10), - ); - let doc = Document::load_mem(pdf.as_bytes()).expect("loads"); - let page = PageInfo { width: 200.0, height: 200.0, crop: [0.0, 0.0, 200.0, 200.0], rotation: 0, label: String::new() }; - let mut info = DocInfo { pages: vec![page.clone(), page], ..Default::default() }; - Inspector::new(&doc).fill(&mut info); - assert!(info.annotations.len() < 1100, "the comment budget filled"); - assert_eq!(info.links.len(), 1); - assert!(info.has_javascript); - assert_eq!(info.attachments.len(), 1); - } - - /// Widgets without `/P` are placed on the page that lists them with one pass over the pages' - /// `/Annots`, not a search of every page per field: that was O(fields × annotations), a - /// second to open a form of 8,000 fields. A widget two pages list is on the first of them. - #[test] - fn widgets_without_a_page_entry_are_placed_in_one_pass() { - const PAGES: usize = 100; - const PER_PAGE: usize = 10; - let field = |p: usize, k: usize| 4 + PAGES + p * PER_PAGE + k; - let mut pdf = String::from("%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 3 0 R >> endobj\n"); - let kids: Vec = (0..PAGES).map(|p| format!("{} 0 R", 4 + p)).collect(); - pdf += &format!("2 0 obj << /Type /Pages /Kids [{}] /Count {PAGES} >> endobj\n", kids.join(" ")); - let fields: Vec = (0..PAGES).flat_map(|p| (0..PER_PAGE).map(move |k| format!("{} 0 R", field(p, k)))).collect(); - pdf += &format!("3 0 obj << /Fields [{}] >> endobj\n", fields.join(" ")); - for p in 0..PAGES { - let mut annots: Vec = (0..PER_PAGE).map(|k| format!("{} 0 R", field(p, k))).collect(); - // The last page lists the first page's first widget too. - if p == PAGES - 1 { - annots.push(format!("{} 0 R", field(0, 0))); - } - pdf += &format!("{} 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [{}] >> endobj\n", 4 + p, annots.join(" ")); - } - for p in 0..PAGES { - for k in 0..PER_PAGE { - pdf += &format!("{} 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /T (f{p}_{k}) /Rect [10 10 90 30] >> endobj\n", field(p, k)); - } - } - pdf += "trailer << /Root 1 0 R >>\n%%EOF"; - let doc = Document::load_mem(pdf.as_bytes()).expect("loads"); - let counting = CountingReads { doc: &doc, reads: std::cell::Cell::new(0) }; - let mut info = DocInfo { - pages: (0..PAGES) - .map(|_| PageInfo { width: 200.0, height: 200.0, crop: [0.0, 0.0, 200.0, 200.0], rotation: 0, label: String::new() }) - .collect(), - ..Default::default() - }; - Inspector::new(&counting).fill(&mut info); - let placed: Vec<_> = info.fields.iter().map(|f| (f.name.clone(), f.page)).collect(); - let want: Vec<_> = (0..PAGES).flat_map(|p| (0..PER_PAGE).map(move |k| (format!("f{p}_{k}"), Some(p)))).collect(); - assert_eq!(placed, want); - // A handful of reads per page and field; searching the pages for each field read the - // page dictionaries about 50,000 times. - let reads = counting.reads.get(); - assert!(reads < 20 * (PAGES + PAGES * PER_PAGE), "{reads} object reads"); - } - - /// #2365: the issue's fixture is two PDFs concatenated, the second cut off before its - /// cross-reference data, and the first's only trailer is a cross-reference stream. The - /// lazy reader refuses a file that needed repair, and lopdf's rebuild found no `trailer` - /// keyword and failed, so Document Properties showed every field empty. The rebuild now - /// takes the cross-reference stream's dictionary as the trailer. - #[test] - fn rebuilt_file_keeps_the_info_of_its_cross_reference_stream() { - let header = "%PDF-1.5\n"; - let obj1 = "1 0 obj<>endobj\n"; - let obj2 = "2 0 obj<>endobj\n"; - let obj3 = "3 0 obj<>endobj\n"; - let obj4 = "4 0 obj<>endobj\n"; - let o1 = header.len(); - let o2 = o1 + obj1.len(); - let o3 = o2 + obj2.len(); - let o4 = o3 + obj3.len(); - let o5 = o4 + obj4.len(); - let rows: Vec = - [[0u8, 0, 0, 255], [1, 0, o1 as u8, 0], [1, 0, o2 as u8, 0], [1, 0, o3 as u8, 0], [1, 0, o4 as u8, 0], [1, (o5 >> 8) as u8, o5 as u8, 0]] - .concat(); - let mut bytes = format!("{header}{obj1}{obj2}{obj3}{obj4}").into_bytes(); - bytes.extend_from_slice(format!("5 0 obj<>stream\n", rows.len()).as_bytes()); - bytes.extend_from_slice(&rows); - // A wrong startxref, and a second file after %%EOF: every reader rebuilds the table. - bytes.extend_from_slice(b"\nendstream\nendobj\nstartxref\n999999\n%%EOF\n%PDF-1.3\n"); - bytes.extend_from_slice(&[b'x'; 1024]); - - let info = inspect(Arc::new(bytes), None).unwrap(); - assert_eq!(info.pages.len(), 1); - assert_eq!(info.title.as_deref(), Some("Kept")); - assert_eq!(info.creator.as_deref(), Some("Me")); - } - - /// #2365 review: the rebuild prefers a trailer whose /Root is the catalog. A later - /// cross-reference stream whose /Root points at the Info dictionary used to replace the - /// earlier `trailer`, so Document Properties lost the catalog it should have read. - #[test] - fn rebuilt_file_prefers_a_trailer_whose_root_is_the_catalog() { - let mut bytes = b"%PDF-1.5\n".to_vec(); - for body in [ - "<>", - "<>", - "<>", - "<>", - ] { - let n = bytes.iter().filter(|b| **b == b'\n').count(); - bytes.extend_from_slice(format!("{n} 0 obj{body}endobj\n").as_bytes()); - } - bytes.extend_from_slice(b"trailer\n<>\n"); - bytes.extend_from_slice(b"5 0 obj<>stream\n\x01\x00\x09\x00\nendstream\nendobj\n"); - // A wrong startxref, and a second file after %%EOF: every reader rebuilds the table. - bytes.extend_from_slice(b"startxref\n999999\n%%EOF\n%PDF-1.3\n"); - bytes.extend_from_slice(&[b'x'; 1024]); - - let info = inspect(Arc::new(bytes.clone()), None).unwrap(); - assert_eq!(info.pages.len(), 1); - assert_eq!(info.title.as_deref(), Some("Kept")); - // A later stream whose /Root names the catalog's number with another generation does - // not count either: loading `1 1 R` finds no such object. - let at = bytes.windows(10).position(|w| w == b"/Root 4 0 ").unwrap(); - bytes[at..at + 10].copy_from_slice(b"/Root 1 1 "); - let info = inspect(Arc::new(bytes), None).unwrap(); - assert_eq!((info.pages.len(), info.title.as_deref()), (1, Some("Kept"))); - } - - #[test] - fn lazy_structure_matches_the_compatibility_inspector() { - // Each fixture as lopdf writes it, and with its objects in object streams. - let packed = |bytes: &[u8]| { - let doc = pdfcraft_cos::Document::open(Arc::new(bytes.to_vec())).unwrap(); - pdfcraft_cos::write_full(&doc, &pdfcraft_cos::SaveOptions { object_streams: true, ..Default::default() }).unwrap() - }; - let fixtures = [ATTACHMENTS, LAYERS, DESTS].into_iter().flat_map(|bytes| { - let mut original = Document::load_mem(bytes).unwrap(); - let mut plain = Vec::new(); - original.save_to(&mut plain).unwrap(); - let stm = packed(&plain); - [plain, stm] - }); - for bytes in fixtures { - let lazy = LazyStructure::new(Arc::new(bytes.clone()), None).unwrap(); - let eager = Document::load_mem(&bytes).unwrap(); - let mut a = inspect(Arc::new(bytes), None).unwrap(); - let mut b = DocInfo { pages: a.pages.clone(), ..Default::default() }; - let mut c = DocInfo { pages: a.pages.clone(), ..Default::default() }; - Inspector::new(&lazy).fill(&mut b); - Inspector::new(&eager).fill(&mut c); - assert!(!lazy.failed()); - assert_eq!(format!("{b:?}"), format!("{c:?}")); - a.file_size = 0; - a.pdf_version.clear(); - assert_eq!(format!("{a:?}"), format!("{c:?}")); - } - } - - #[test] - fn links_read_set_layer_actions() { - let info = inspect(Arc::new(LAYERS.to_vec()), None).expect("opens"); - let targets: Vec<_> = info.links.iter().map(|l| l.target.clone()).collect(); - use LayerOp::{Off, On, Toggle}; - assert_eq!( - targets, - [ - // Groups before the first name or after an unknown one are skipped, and so is - // anything that isn't a group. - LinkTarget::SetLayers { changes: vec![(On, (6, 0)), (Toggle, (5, 0)), (Toggle, (7, 0))], preserve_rb: false }, - // An indirect /State; /PreserveRB defaults to true. - LinkTarget::SetLayers { changes: vec![(Off, (5, 0))], preserve_rb: true }, - ] - ); - } - - /// #4168: a Launch action naming a `.pdf` file is read as the link to that file it amounts - /// to, `\` separators included, and a stray `/D` (which Launch has no use for) doesn't stop - /// it. One naming anything else, with no file, or with `/Win` launch parameters stays an - /// action that isn't followed. - #[test] - fn launch_actions_that_name_a_pdf_open_it_like_a_remote_link() { - let pdf = "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ - 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >> endobj\n\ - 3 0 obj << /Type /Page /Parent 2 0 R /Annots [30 0 R 31 0 R 32 0 R 33 0 R 34 0 R 35 0 R 36 0 R 37 0 R 38 0 R] >> endobj\n\ - 30 0 obj << /Type /Annot /Subtype /Link /Rect [0 0 10 10] /A << /S /Launch /F (..\\\\se\\\\se_ST.pdf) >> >> endobj\n\ - 31 0 obj << /Type /Annot /Subtype /Link /Rect [0 20 10 30] /A << /S /Launch /F << /Type /Filespec /F (chapters/Two.PDF) >> /NewWindow true >> >> endobj\n\ - 32 0 obj << /Type /Annot /Subtype /Link /Rect [0 40 10 50] /A << /S /Launch /F (tool.exe) >> >> endobj\n\ - 33 0 obj << /Type /Annot /Subtype /Link /Rect [0 60 10 70] /A << /S /Launch /F (a.pdf) /Win << /F (cmd.exe) /P (/c x) >> >> >> endobj\n\ - 34 0 obj << /Type /Annot /Subtype /Link /Rect [0 80 10 90] /A << /S /Launch /Win << /F (b.pdf) >> >> >> endobj\n\ - 35 0 obj << /Type /Annot /Subtype /Link /Rect [0 100 10 110] /A << /S /Launch /F (.pdf) >> >> endobj\n\ - 36 0 obj << /Type /Annot /Subtype /Link /Rect [0 120 10 130] /A << /S /Launch /F (dir/a\\\\b.pdf) >> >> endobj\n\ - 37 0 obj << /Type /Annot /Subtype /Link /Rect [0 140 10 150] /A << /S /Launch /FS /URL /F (https://example.com/x.pdf) >> >> endobj\n\ - 38 0 obj << /Type /Annot /Subtype /Link /Rect [0 160 10 170] /A << /S /Launch /F (c.pdf) /D 0 >> >> endobj\n\ - trailer << /Root 1 0 R >>\n%%EOF"; - let info = inspect(Arc::new(pdf.as_bytes().to_vec()), None).expect("opens"); - let targets: Vec<_> = info.links.iter().map(|l| l.target.clone()).collect(); - let launch = - |file: &str, new_window| LinkTarget::RemoteGoTo { launch: true, file: file.into(), destination: RemoteDestination::Default, new_window }; - let other = || LinkTarget::Other("Launch".into()); - assert_eq!( - targets, - [ - launch("../se/se_ST.pdf", None), - launch("chapters/Two.PDF", Some(true)), - other(), - other(), - other(), - other(), - launch("dir/a\\b.pdf", None), - other(), - launch("c.pdf", None), - ] - ); - } - - #[test] - fn links_read_bounded_local_remote_go_to_actions() { - use DestView::{Fit, Xyz}; - let too_long = "a".repeat(MAX_REMOTE_FILE_SPEC_BYTES + 1); - let wide_file = format!("", "4E00".repeat(1400)); - let wide_name = format!("", "4E00".repeat(342)); - let pdf = format!( - "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ - 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >> endobj\n\ - 3 0 obj << /Type /Page /Parent 2 0 R /Annots [99 0 R 30 0 R 31 0 R 32 0 R 33 0 R 34 0 R 35 0 R 36 0 R 37 0 R 38 0 R 39 0 R 40 0 R 41 0 R] >> endobj\n\ - 30 0 obj << /Type /Annot /Subtype /Link /Rect [0 0 10 10] /A << /S /GoToR /F (chapters/remote.pdf) /D [2 /XYZ 10 20 1.5] /NewWindow true >> >> endobj\n\ - 31 0 obj << /Type /Annot /Subtype /Link /Rect [0 20 10 30] /A << /S /GoToR /F << /Type /Filespec /F (fallback.pdf) /UF >> /D [0 /Fit] /NewWindow false >> >> endobj\n\ - 32 0 obj << /Type /Annot /Subtype /Link /Rect [0 40 10 50] /A << /S /GoToR /F (remote.pdf) >> >> endobj\n\ - 33 0 obj << /Type /Annot /Subtype /Link /Rect [0 60 10 70] /A << /S /GoToR /F (chapter.pdf) /D (chapter) >> >> endobj\n\ - 34 0 obj << /Type /Annot /Subtype /Link /Rect [0 80 10 90] /A << /S /GoToR /FS /URL /F (https://example.com) /D [0 /Fit] >> >> endobj\n\ - 35 0 obj << /Type /Annot /Subtype /Link /Rect [0 100 10 110] /A << /S /GoToR /F ({too_long}) /D [0 /Fit] >> >> endobj\n\ - 36 0 obj << /Type /Annot /Subtype /Link /Rect [0 120 10 130] /A << /S /GoToR /F (chapter.pdf) /D /chapter >> >> endobj\n\ - 37 0 obj << /Type /Annot /Subtype /Link /Rect [0 140 10 150] /A << /S /GoToR /F (chapter.pdf) /D [-1 /Fit] >> >> endobj\n\ - 38 0 obj << /Type /Annot /Subtype /Link /Rect [0 160 10 170] /A << /S /GoToR /F (chapter.pdf) /D [1.5 /Fit] >> >> endobj\n\ - 39 0 obj << /Type /Annot /Subtype /Link /Rect [0 180 10 190] /A << /S /GoToR /F (chapter.pdf) /D [9223372036854775807 /Fit] >> >> endobj\n\ - 40 0 obj << /Type /Annot /Subtype /Link /Rect [0 200 10 210] /A << /S /GoToR /F << /Type /Filespec /UF {wide_file} >> /D [0 /Fit] >> >> endobj\n\ - 41 0 obj << /Type /Annot /Subtype /Link /Rect [0 220 10 230] /A << /S /GoToR /F (chapter.pdf) /D {wide_name} >> >> endobj\n\ - trailer << /Root 1 0 R >>\n%%EOF" - ); - let info = inspect(Arc::new(pdf.into_bytes()), None).expect("opens"); - let targets: Vec<_> = info.links.iter().map(|l| (l.index, l.target.clone())).collect(); - let huge_page_target = usize::try_from(i64::MAX) - .map(|page| LinkTarget::RemoteGoTo { - launch: false, - file: "chapter.pdf".into(), - destination: RemoteDestination::Page { page, view: Fit }, - new_window: None, - }) - .unwrap_or_else(|_| LinkTarget::Other("GoToR".into())); - assert_eq!( - targets, - [ - ( - 1, - LinkTarget::RemoteGoTo { - launch: false, - file: "chapters/remote.pdf".into(), - destination: RemoteDestination::Page { page: 2, view: Xyz { left: Some(10.0), top: Some(20.0), zoom: Some(1.5) } }, - new_window: Some(true), - } - ), - ( - 2, - LinkTarget::RemoteGoTo { - launch: false, - file: "foo.pdf".into(), - destination: RemoteDestination::Page { page: 0, view: Fit }, - new_window: Some(false), - } - ), - (3, LinkTarget::RemoteGoTo { launch: false, file: "remote.pdf".into(), destination: RemoteDestination::Default, new_window: None }), - ( - 4, - LinkTarget::RemoteGoTo { - launch: false, - file: "chapter.pdf".into(), - destination: RemoteDestination::Named("chapter".into()), - new_window: None - } - ), - (5, LinkTarget::Other("GoToR".into())), - (6, LinkTarget::Other("GoToR".into())), - ( - 7, - LinkTarget::RemoteGoTo { - launch: false, - file: "chapter.pdf".into(), - destination: RemoteDestination::Named("chapter".into()), - new_window: None - } - ), - (8, LinkTarget::Other("GoToR".into())), - (9, LinkTarget::Other("GoToR".into())), - (10, huge_page_target), - (11, LinkTarget::Other("GoToR".into())), - (12, LinkTarget::Other("GoToR".into())), - ] - ); - } - - #[test] - fn destination_operands_become_views() { - use DestView::*; - let v = |kind: &[u8], ops: &[Option]| DestView::from_operands(Some(kind), ops); - // ISO 32000-2 Table 149, each type. - assert_eq!(v(b"XYZ", &[Some(0.0), Some(420.0), Some(0.0)]), Xyz { left: Some(0.0), top: Some(420.0), zoom: None }); - assert_eq!(v(b"XYZ", &[Some(72.0), Some(500.0), Some(1.5)]), Xyz { left: Some(72.0), top: Some(500.0), zoom: Some(1.5) }); - assert_eq!(v(b"Fit", &[]), Fit); - assert_eq!(v(b"FitB", &[]), Fit); - assert_eq!(v(b"FitH", &[Some(250.0)]), FitH { top: Some(250.0) }); - assert_eq!(v(b"FitBH", &[None]), FitH { top: None }); - assert_eq!(v(b"FitV", &[Some(120.0)]), FitV { left: Some(120.0) }); - assert_eq!(v(b"FitBV", &[Some(5.0)]), FitV { left: Some(5.0) }); - // FitR's corners are normalized. - assert_eq!(v(b"FitR", &[Some(110.0), Some(220.0), Some(10.0), Some(20.0)]), FitR { rect: [10.0, 20.0, 110.0, 220.0] }); - // Missing operands are null; so are non-finite ones, and a zoom that isn't positive. - assert_eq!(v(b"XYZ", &[]), Xyz { left: None, top: None, zoom: None }); - assert_eq!(v(b"XYZ", &[Some(f32::NAN), Some(f32::INFINITY), Some(f32::NEG_INFINITY)]), Xyz { left: None, top: None, zoom: None }); - assert_eq!(v(b"XYZ", &[None, Some(-5.0), Some(-1.0)]), Xyz { left: None, top: Some(-5.0), zoom: None }); - assert_eq!(v(b"XYZ", &[None, None, Some(f32::NAN)]), Xyz { left: None, top: None, zoom: None }); - assert_eq!(v(b"FitH", &[Some(f32::NAN)]), FitH { top: None }); - // A FitR without four usable numbers, an unknown type or no type: the top of the page. - assert_eq!(v(b"FitR", &[Some(1.0), Some(2.0)]), Top); - assert_eq!(v(b"FitR", &[Some(1.0), Some(2.0), Some(f32::INFINITY), Some(4.0)]), Top); - assert_eq!(v(b"Zoom", &[Some(3.0)]), Top); - assert_eq!(v(b"xyz", &[Some(3.0)]), Top); - assert_eq!(DestView::from_operands(None, &[Some(1.0)]), Top); - } - - /// Two 300×400 pages. Bookmarks and links on page 1 go to page 2 in every destination form, - /// named (`/Names /Dests` and the PDF 1.1 `/Dests`) and malformed. - const DESTS: &[u8] = b"%PDF-1.7 -1 0 obj << /Type /Catalog /Pages 2 0 R /Outlines 10 0 R /Names << /Dests 40 0 R >> /Dests << /old << /D [4 0 R /FitV 120] >> >> >> endobj -2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 400] >> endobj -3 0 obj << /Type /Page /Parent 2 0 R /Annots [30 0 R 31 0 R 32 0 R 33 0 R] >> endobj -4 0 obj << /Type /Page /Parent 2 0 R >> endobj -10 0 obj << /Type /Outlines /First 11 0 R /Last 20 0 R /Count 10 >> endobj -11 0 obj << /Title (xyz) /Parent 10 0 R /Next 12 0 R /Dest [4 0 R /XYZ 0 420 0] >> endobj -12 0 obj << /Title (fith) /Parent 10 0 R /Next 13 0 R /Dest [4 0 R /FitH 250] >> endobj -13 0 obj << /Title (named) /Parent 10 0 R /Next 14 0 R /Dest (sec) >> endobj -14 0 obj << /Title (old named) /Parent 10 0 R /Next 15 0 R /Dest /old >> endobj -15 0 obj << /Title (action) /Parent 10 0 R /Next 16 0 R /A << /S /GoTo /D [4 0 R /FitR 110 220 10 20] >> >> endobj -16 0 obj << /Title (wrong types) /Parent 10 0 R /Next 17 0 R /Dest [4 0 R /XYZ (left) /Top true] >> endobj -17 0 obj << /Title (short) /Parent 10 0 R /Next 18 0 R /Dest [4 0 R /FitR 1 2] >> endobj -18 0 obj << /Title (no type) /Parent 10 0 R /Next 19 0 R /Dest [4 0 R] >> endobj -19 0 obj << /Title (huge) /Parent 10 0 R /Next 20 0 R /Dest [4 0 R /XYZ 1000000000000 -5 -1] >> endobj -20 0 obj << /Title (no page) /Parent 10 0 R /Dest [/XYZ 0 420 0] >> endobj -30 0 obj << /Type /Annot /Subtype /Link /Rect [0 0 10 10] /Dest [4 0 R /XYZ 72 500 1.5] >> endobj -31 0 obj << /Type /Annot /Subtype /Link /Rect [0 20 10 30] /A << /S /GoTo /D (sec) >> >> endobj -32 0 obj << /Type /Annot /Subtype /Link /Rect [0 40 10 50] /Dest [4 0 R /FitBH 100] >> endobj -33 0 obj << /Type /Annot /Subtype /Link /Rect [0 60 10 70] /Dest [4 0 R /Fit] >> endobj -40 0 obj << /Names [(sec) << /D [4 0 R /XYZ null 300 2] >>] >> endobj -trailer << /Root 1 0 R >> -%%EOF"; - - #[test] - fn bookmarks_and_links_keep_their_destination_view() { - use DestView::*; - let info = inspect(Arc::new(DESTS.to_vec()), None).expect("opens"); - let outline: Vec<_> = info.outline.iter().map(|o| (o.title.as_str(), o.page, o.view)).collect(); - assert_eq!( - outline, - [ - ("xyz", Some(1), Xyz { left: Some(0.0), top: Some(420.0), zoom: None }), - ("fith", Some(1), FitH { top: Some(250.0) }), - ("named", Some(1), Xyz { left: None, top: Some(300.0), zoom: Some(2.0) }), - ("old named", Some(1), FitV { left: Some(120.0) }), - ("action", Some(1), FitR { rect: [10.0, 20.0, 110.0, 220.0] }), - // Hostile operands fall back without failing the bookmark. - ("wrong types", Some(1), Xyz { left: None, top: None, zoom: None }), - ("short", Some(1), Top), - ("no type", Some(1), Top), - ("huge", Some(1), Xyz { left: Some(1e12), top: Some(-5.0), zoom: None }), - ("no page", None, Top), - ] - ); - let links: Vec<_> = info.links.iter().map(|l| l.target.clone()).collect(); - assert_eq!( - links, - [ - LinkTarget::Page(1, Xyz { left: Some(72.0), top: Some(500.0), zoom: Some(1.5) }), - LinkTarget::Page(1, Xyz { left: None, top: Some(300.0), zoom: Some(2.0) }), - LinkTarget::Page(1, FitH { top: Some(100.0) }), - LinkTarget::Page(1, Fit), - ] - ); - } - - #[test] - fn outline_titles_decode_every_text_string_encoding() { - // Issue #142: CJK bookmark titles showed as mojibake or with a stray BOM. - let pdf = b"%PDF-1.7 -1 0 obj << /Type /Catalog /Pages 2 0 R /Outlines 10 0 R >> endobj -2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >> endobj -3 0 obj << /Type /Page /Parent 2 0 R >> endobj -10 0 obj << /Type /Outlines /First 11 0 R /Last 16 0 R /Count 6 >> endobj -11 0 obj << /Title /Parent 10 0 R /Next 12 0 R /Dest [3 0 R /Fit] >> endobj -12 0 obj << /Title /Parent 10 0 R /Next 13 0 R /Dest [3 0 R /Fit] >> endobj -13 0 obj << /Title /Parent 10 0 R /Next 14 0 R /Dest [3 0 R /Fit] >> endobj -14 0 obj << /Title /Parent 10 0 R /Next 15 0 R /Dest [3 0 R /Fit] >> endobj -15 0 obj << /Title <436166E9> /Parent 10 0 R /Next 16 0 R /Dest [3 0 R /Fit] >> endobj -16 0 obj << /Title /Parent 10 0 R /Dest [3 0 R /Fit] >> endobj -trailer << /Root 1 0 R >> -%%EOF"; - let info = inspect(Arc::new(pdf.to_vec()), None).expect("opens"); - let titles: Vec<_> = info.outline.iter().map(|o| o.title.as_str()).collect(); - // UTF-16BE, UTF-8 with BOM, invalid UTF-8 after a BOM (lossy, not dropped), BOM-less - // raw UTF-8, PDFDocEncoding Latin-1, and a doubled BOM. - assert_eq!(titles, ["第一章", "目录", "A\u{FFFD}B", "概述", "Café", "A"]); - } - - #[test] - fn xfa_dynamic_render_is_read_from_config_acrobat7_only() { - let text = xfa_config_dynamic_render; - let config = - |inner: &str| format!("{inner}"); - // Designer's style: a line break before each `>`. - assert_eq!( - text("forbidden").as_deref(), - Some("forbidden") - ); - assert_eq!(text(&config(" required ")).as_deref(), Some("required")); - // Commented out, in CDATA, or a longer name: not the element. - assert_eq!(text(&config("")), None); - assert_eq!(text(&config(" b forbidden ]]>")), None); - assert_eq!(text(&config("forbidden")), None); - // Self-closing, empty, or holding markup: no value. - assert_eq!(text(&config("")), None); - assert_eq!(text(&config("")), None); - assert_eq!(text(&config("forbidden")), None); - // Closing tags that don't match what they close: malformed, no value (review on #3192). - assert_eq!(text(&config("forbidden")), None); - assert_eq!(text("forbidden"), None); - // The same name anywhere but config/acrobat/acrobat7 (form data, another config branch). - let xdp = "forbiddenforbidden"; - assert_eq!(text(xdp), None); - // A whole XDP with the config after the template. - let xdp = format!("{}", config("forbidden")); - assert_eq!(text(&xdp).as_deref(), Some("forbidden")); - // A whole config path inside the form data, before the real config packet, is data: the - // real packet's value counts (review on #3192). - let xdp = format!( - "{}{}", - config("forbidden"), - config("required") - ); - assert_eq!(text(&xdp).as_deref(), Some("required")); - // Only in the data, or nested in the template: no configuration at all. - let xdp = - format!("{}", config("forbidden")); - assert_eq!(text(&xdp), None); - let xdp = format!("", config("forbidden")); - assert_eq!(text(&xdp), None); - // acrobat7 must be a child of acrobat, not deeper. - assert_eq!(text("forbidden"), None); - // Cut short anywhere: no panic, nothing read. - let whole = config("forbidden"); - for end in 0..whole.len() { - assert_eq!(text(&whole[..end]), None, "cut at {end}"); - } - assert_eq!(text(&whole).as_deref(), Some("forbidden")); - } - - #[test] - fn xfa_config_packets_are_read_in_utf16_too() { - let xml = "forbidden"; - let le: Vec = xml.encode_utf16().flat_map(u16::to_le_bytes).collect(); - let be: Vec = xml.encode_utf16().flat_map(u16::to_be_bytes).collect(); - for bytes in [ - le.clone(), - be.clone(), - [&[0xFF, 0xFE][..], &le].concat(), - [&[0xFE, 0xFF][..], &be].concat(), - [&[0xEF, 0xBB, 0xBF][..], xml.as_bytes()].concat(), - ] { - assert_eq!(xfa_packet_text(&bytes), xml); - } - // An odd byte or a broken surrogate doesn't stop the scan. - assert!(xfa_packet_text(&[0xFF, 0xFE, b'<', 0, 0x00, 0xD8, b'x']).starts_with('<')); - } - - /// #3180: an XFA form with no fields was always taken to be dynamic, so its pages were - /// replaced by a layout on open, even when its configuration says it is a static form. - #[test] - fn an_xfa_form_without_fields_is_static_when_its_config_forbids_dynamic_rendering() { - let xfa_of = |config: &str, catalog_extra: &str| { - let bodies = [ - format!("<< /Type /Catalog /Pages 2 0 R {catalog_extra} /AcroForm << /Fields [] /XFA [(config) 4 0 R (template) 5 0 R] >> >>"), - "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>".to_string(), - "<< /Type /Page /Parent 2 0 R >>".to_string(), - format!("<< /Length {} >>\nstream\n{config}\nendstream", config.len()), - "<< /Length 11 >>\nstream\n", next); + let form = layout_xml(&xdp).unwrap(); + assert_eq!(form.pages.len(), 1); + form.pages[0].items.iter().find_map(|i| if let Item::Text(s) = i { Some(s.baseline) } else { None }).expect("the next block") + }; + // Upright the box ends 0.4in below the anchor, and the next block starts there. + let upright = baseline(""); + for (rotate, bottom) in [("90", 0.0), ("180", 0.0), ("270", 50.4)] { + let got = baseline(&format!(r#"rotate="{rotate}""#)) - (upright - 28.8); + assert!((got - bottom).abs() < 0.01, "{rotate}: the next block starts {got} below the anchor, not {bottom}"); + } +} + +/// A subform with no width is as wide as what it holds as it stands, whichever way that turned, +/// so a neighbour in a row doesn't stand off from it or over it. +#[test] +fn a_container_is_as_wide_as_a_turned_draw_stands() { + let row = |rotate: &str| { + let xdp = form_of_draws( + "", + &format!( + r#"{}beside"#, + line_draw("1in", "1in", rotate) + ), + ) + .replace(r#""#, r#""#); + let form = layout_xml(&xdp).unwrap(); + form.pages[0].items.iter().find_map(|i| if let Item::Text(s) = i { Some(s.x) } else { None }).expect("the neighbour") + }; + let upright = row(""); + // Upright it ends 0.7in right of the anchor; turned, 0.4in right, at the anchor, and at it again. + for (rotate, right) in [("90", 28.8), ("180", 0.0), ("270", 0.0)] { + let got = row(&format!(r#"rotate="{rotate}""#)) - (upright - 50.4); + assert!((got - right).abs() < 0.01, "{rotate}: the neighbour stands {got} right of the anchor, not {right}"); + } + // From a bottom-right anchor the box starts behind it, and the same turns take it the other way. + for (rotate, right) in [("90", 0.0), ("180", 50.4), ("270", 28.8)] { + let got = row(&format!(r#"anchorType="bottomRight" rotate="{rotate}""#)) - (upright - 50.4); + assert!((got - right).abs() < 0.01, "{rotate}: the neighbour stands {got} right of the anchor, not {right}"); + } +} diff --git a/crates/xfdf/Cargo.toml b/crates/xfdf/Cargo.toml index 3ede565030..f868c95719 100644 --- a/crates/xfdf/Cargo.toml +++ b/crates/xfdf/Cargo.toml @@ -13,5 +13,5 @@ pdfcraft-cos = { workspace = true } pdfcraft-annot = { workspace = true } pdfcraft-forms = { workspace = true } pdfcraft-model = { workspace = true } -roxmltree = "0.20" +roxmltree = "0.21" thiserror = { workspace = true } diff --git a/crates/xfdf/README.md b/crates/xfdf/README.md index c137aabd93..d286a457c3 100644 --- a/crates/xfdf/README.md +++ b/crates/xfdf/README.md @@ -40,7 +40,9 @@ including CDATA and character references. Nested-element and XFA interpretation CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities. Automatic detection of externally authored, ambiguous -tables remains heuristic. +tables remains heuristic. Both write a cell that a spreadsheet would run as a formula (`=` or `@`, +or `+` or `-` when it isn't a plain number) with a leading `'`; CSV import takes off the `'` that +export added, so exported data imports back unchanged. Import replaces comments with the same `/NM` (and drops the replaced comment's pop-up), draws appearances PdfCraft can draw, and sets values through the form's own checks (formats, diff --git a/crates/xfdf/src/common.rs b/crates/xfdf/src/common.rs new file mode 100644 index 0000000000..488277dc19 --- /dev/null +++ b/crates/xfdf/src/common.rs @@ -0,0 +1,266 @@ +use super::{DataError, Dict, Document, Object}; + +// ── names ─────────────────────────────────────────────────────────────────────────────────── + +/// Form data files are attacker-supplied. Refuse a DTD and bound the node count, exactly as the +/// XFA template parser does (`crates/xfa/src/parse.rs`), and bound how deep a nested field tree +/// may go: the walk below recurses once per nesting level. +const MAX_NODES: u32 = 2_000_000; + +pub(crate) const MAX_DEPTH: usize = 64; + +/// The deepest element nesting accepted before the text reaches the XML parser. +/// +/// roxmltree parses by recursive descent, so its stack use grows with the nesting depth: ten +/// thousand levels overflow the main thread's stack and abort the process. A stack overflow is +/// `abort()`, so `catch_unwind` cannot intercept it and the whole application dies - which is why +/// the bound has to be applied before parsing, not while walking the result. Around three thousand +/// levels still parse on the default stack, so this stays clear of the cliff while leaving far more +/// room than the walks below, which cut off at 64. +pub(crate) const MAX_XML_DEPTH: usize = 1_024; + +/// Element nesting depth of `text`, skipping comments, CDATA, processing instructions, the DOCTYPE +/// and quoted attribute values. A tag that closes itself (``) is one level while it is read +/// and does not stay open, so a long run of them cannot accumulate into the bound. It cannot +/// under-count a real element; a stray `<` in text is invalid XML and only makes it over-count, +/// which errs towards refusing the file. +pub(crate) fn xml_nesting_depth(text: &str) -> usize { + fn find(hay: &[u8], from: usize, needle: &[u8]) -> Option { + (from..hay.len()).find(|&p| hay[p..].starts_with(needle)) + } + let b = text.as_bytes(); + let (mut i, mut depth, mut max) = (0usize, 0usize, 0usize); + while i < b.len() { + if b[i] != b'<' { + i += 1; + } else if b[i..].starts_with(b"").map_or(b.len(), |p| p + 3); + } else if b[i..].starts_with(b"").map_or(b.len(), |p| p + 3); + } else if b[i..].starts_with(b"").map_or(b.len(), |p| p + 2); + } else if b[i..].starts_with(b"").map_or(b.len(), |p| p + 1); + } else if b[i..].starts_with(b"").map_or(b.len(), |p| p + 1); + } else { + depth += 1; + max = max.max(depth); + // Skip the rest of the start tag, over any quoted attribute value, and remember its last + // significant byte: a tag that ends in `/` closes itself, so it is one level deep while it + // is read rather than a level left open. + i += 1; + let mut quote = 0u8; + let mut last = 0u8; + while i < b.len() { + let c = b[i]; + if quote != 0 { + if c == quote { + quote = 0; + } + } else if c == b'"' || c == b'\'' { + quote = c; + } else if c == b'>' { + i += 1; + break; + } else if !c.is_ascii_whitespace() { + last = c; + } + i += 1; + } + if last == b'/' { + depth -= 1; + } + } + } + max +} + +pub(crate) fn check_form_xml_depth(text: &str) -> Result<(), DataError> { + let depth = xml_nesting_depth(text); + if depth > MAX_XML_DEPTH { + return Err(DataError::Malformed(format!("the file nests XML elements {depth} deep, past the {MAX_XML_DEPTH} the reader accepts"))); + } + Ok(()) +} + +pub(crate) fn parse_form_xml(text: &str) -> Result, roxmltree::Error> { + roxmltree::Document::parse_with_options(text, roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: MAX_NODES, ..Default::default() }) +} + +pub(crate) const SUBTYPES: [(&str, &str); 17] = [ + ("Text", "text"), + ("FreeText", "freetext"), + ("Line", "line"), + ("Square", "square"), + ("Circle", "circle"), + ("Polygon", "polygon"), + ("PolyLine", "polyline"), + ("Highlight", "highlight"), + ("Underline", "underline"), + ("Squiggly", "squiggly"), + ("StrikeOut", "strikeout"), + ("Stamp", "stamp"), + ("Caret", "caret"), + ("Ink", "ink"), + ("FileAttachment", "fileattachment"), + ("Sound", "sound"), + ("Redact", "redact"), +]; + +pub(crate) const FLAGS: [(i64, &str); 9] = [ + (1, "invisible"), + (2, "hidden"), + (4, "print"), + (8, "nozoom"), + (16, "norotate"), + (32, "noview"), + (64, "readonly"), + (128, "locked"), + (256, "togglenoview"), +]; + +pub(crate) fn esc(s: &str) -> String { + let mut o = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '&' => o.push_str("&"), + '<' => o.push_str("<"), + '>' => o.push_str(">"), + '"' => o.push_str("""), + // XML 1.0 §3.3.3 normalizes literal attribute whitespace, but not references. + '\t' => o.push_str(" "), + '\n' => o.push_str(" "), + '\r' => o.push_str(" "), + c if (c as u32) < 0x20 && c != '\n' && c != '\t' => {} + // XML 1.0 noncharacters are not allowed even as raw text. + '\u{FFFE}' | '\u{FFFF}' => {} + c => o.push(c), + } + } + o +} + +pub(crate) fn n(v: f64) -> String { + let s = format!("{v:.4}"); + let s = s.trim_end_matches('0').trim_end_matches('.'); + if s == "-0" { "0".into() } else { s.into() } +} + +pub(crate) fn hex(c: &[f64]) -> Option { + let rgb = match c.len() { + 1 => [c[0]; 3], + 3 => [c[0], c[1], c[2]], + 4 => [(1.0 - c[0]) * (1.0 - c[3]), (1.0 - c[1]) * (1.0 - c[3]), (1.0 - c[2]) * (1.0 - c[3])], + _ => return None, + }; + Some(format!( + "#{:02X}{:02X}{:02X}", + (rgb[0].clamp(0.0, 1.0) * 255.0).round() as u8, + (rgb[1].clamp(0.0, 1.0) * 255.0).round() as u8, + (rgb[2].clamp(0.0, 1.0) * 255.0).round() as u8 + )) +} + +pub(crate) fn parse_hex(s: &str) -> Option<[f64; 3]> { + let h = s.trim().trim_start_matches('#'); + if h.len() != 6 || !h.bytes().all(|b| b.is_ascii_hexdigit()) { + return None; + } + let p = |i: usize| h.get(i..i + 2).and_then(|s| u8::from_str_radix(s, 16).ok()).map(|v| f64::from(v) / 255.0); + Some([p(0)?, p(2)?, p(4)?]) +} + +pub(crate) fn nums_of(doc: &Document, o: Option<&Object>) -> Vec { + o.map(|o| doc.resolve(o)).and_then(|a| a.as_array().map(|a| a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect())).unwrap_or_default() +} + +/// A `/Rect` of exactly four finite numbers; any other entry makes the whole rectangle unusable. +pub(crate) fn rect_strict(doc: &Document, o: Option<&Object>) -> Option<[f64; 4]> { + let num = |x: &Object| doc.resolve(x).as_f64().filter(|v| v.is_finite()); + match doc.resolve(o?).as_array()?.as_slice() { + [x0, y0, x1, y1] => Some([num(x0)?, num(y0)?, num(x1)?, num(y1)?]), + _ => None, + } +} + +/// Whether a quad's four corners enclose any area: some three of them are not collinear. +pub(crate) fn quad_has_area(q: &[f64; 8]) -> bool { + let [ax, ay, bx, by, cx, cy, dx, dy] = *q; + let cross = |(px, py): (f64, f64), (qx, qy): (f64, f64), (rx, ry): (f64, f64)| (qx - px) * (ry - py) - (qy - py) * (rx - px); + let (a, b, c, d) = ((ax, ay), (bx, by), (cx, cy), (dx, dy)); + [cross(a, b, c), cross(a, b, d), cross(a, c, d), cross(b, c, d)].iter().any(|v| v.abs() > 0.0) +} + +pub(crate) fn text_of(doc: &Document, d: &Dict, k: &[u8]) -> Option { + d.get(k).and_then(|o| match &*doc.resolve(o) { + Object::String(s) => Some(s.to_text()), + Object::Name(n) => Some(pdfcraft_forms::name_text(n)), + _ => None, + }) +} + +/// The numbers of a list; empty when any entry is non-finite (`nan`, `inf`), so the list is unusable as a whole. +pub(crate) fn csv(list: &str) -> Vec { + let v: Vec = list.split([',', ';', ' ']).filter(|t| !t.is_empty()).filter_map(|t| t.trim().parse().ok()).collect(); + if v.iter().all(|x| x.is_finite()) { v } else { Vec::new() } +} + +pub(crate) fn arr(v: &[f64]) -> Object { + Object::Array(v.iter().map(|x| Object::Real(*x)).collect()) +} + +/// XML 1.0 fifth-edition NameStartChar, excluding `:` for an unprefixed NCName. +/// https://www.w3.org/TR/2008/REC-xml-20081126/#NT-NameStartChar +fn xml_name_start(c: char) -> bool { + matches!( + c, + '_' | 'A'..='Z' | 'a'..='z' + | '\u{C0}'..='\u{D6}' | '\u{D8}'..='\u{F6}' | '\u{F8}'..='\u{2FF}' + | '\u{370}'..='\u{37D}' | '\u{37F}'..='\u{1FFF}' + | '\u{200C}'..='\u{200D}' | '\u{2070}'..='\u{218F}' + | '\u{2C00}'..='\u{2FEF}' | '\u{3001}'..='\u{D7FF}' + | '\u{F900}'..='\u{FDCF}' | '\u{FDF0}'..='\u{FFFD}' + | '\u{10000}'..='\u{EFFFF}' + ) +} + +fn xml_name_char(c: char) -> bool { + xml_name_start(c) || matches!(c, '-' | '.' | '0'..='9' | '\u{B7}' | '\u{300}'..='\u{36F}' | '\u{203F}'..='\u{2040}') +} + +/// Keep the existing letters/digits/`_`/`-`/`.` normalization, constrained to valid NCNames. +pub(crate) fn xml_name(name: &str) -> String { + let mut s: String = + name.chars().map(|c| if (c.is_alphanumeric() && xml_name_char(c)) || matches!(c, '_' | '-' | '.') { c } else { '_' }).collect(); + if !s.chars().next().is_some_and(|c| (c.is_alphabetic() && xml_name_start(c)) || c == '_') { + s.insert(0, '_'); + } + s +} + +pub(crate) fn walk_xfdf(node: roxmltree::Node, prefix: &str, out: &mut Vec<(String, Vec)>, depth: usize) -> Result<(), DataError> { + if depth > MAX_DEPTH { + return Err(DataError::Malformed(format!("XFDF fields nest more than {MAX_DEPTH} deep"))); + } + for f in node.children().filter(|c| c.has_tag_name("field")) { + let Some(name) = f.attribute("name") else { continue }; + let full = if prefix.is_empty() { name.to_string() } else { format!("{prefix}.{name}") }; + let vals: Vec = f + .children() + .filter(|c| c.has_tag_name("value")) + // Comments and processing instructions can split a value into several text nodes. + .map(|v| v.children().filter(|n| n.is_text()).filter_map(|n| n.text()).collect()) + .collect(); + // A leaf with no `` is an empty field (the export writes one that way), so it + // clears the field like FDF's `/V ()`; a field with kids only names their parent. A + // leaf holding only `` is not read yet, so it leaves the field alone. + let names_kids_or_rich = f.children().any(|c| c.has_tag_name("field") || c.has_tag_name("value-richtext")); + if !vals.is_empty() || !names_kids_or_rich { + out.push((full.clone(), vals)); + } + walk_xfdf(f, &full, out, depth + 1)?; + } + Ok(()) +} diff --git a/crates/xfdf/src/fdf.rs b/crates/xfdf/src/fdf.rs new file mode 100644 index 0000000000..ceb4ad99eb --- /dev/null +++ b/crates/xfdf/src/fdf.rs @@ -0,0 +1,516 @@ +use super::common::{MAX_DEPTH, SUBTYPES, rect_strict, text_of}; +use super::xfdf::{add_popup, apply_values, find_by_name, place}; +use super::*; + +fn pdf_string(s: &str) -> Vec { + let mut v = Vec::new(); + pdfcraft_cos::serialize(&Object::String(PdfString::text(s)), &mut v); + v +} + +// Keep aliases as aliases instead of expanding a shared graph once per use. References are +// queued, not followed recursively, so cycles and long indirect chains cost linear work. +pub(crate) const MAX_FDF_OBJECTS: usize = 100_000; + +pub(crate) const MAX_FDF_BYTES: usize = 64 * 1024 * 1024; + +/// Direct values stay borrowed: `Document::resolve` would deep-clone them. Only +/// indirect values need an owner, and annotation bodies are never retained in discovery. +enum FdfValue<'a> { + Borrowed(&'a Object), + Indirect(std::sync::Arc), +} + +impl<'a> FdfValue<'a> { + fn resolve(doc: &Document, value: &'a Object) -> Self { + match value { + Object::Ref(_) => Self::Indirect(doc.resolve(value)), + _ => Self::Borrowed(value), + } + } + + fn object(&self) -> &Object { + match self { + Self::Borrowed(value) => value, + Self::Indirect(value) => value.as_ref(), + } + } +} + +struct FdfCopy<'a> { + doc: &'a Document, + objects: Vec, + numbers: HashMap, + pending: Vec<(ObjRef, usize)>, + nodes: usize, + bytes: usize, +} + +impl<'a> FdfCopy<'a> { + fn new(doc: &'a Document) -> Self { + Self { doc, objects: Vec::new(), numbers: HashMap::new(), pending: Vec::new(), nodes: MAX_FDF_OBJECTS, bytes: MAX_FDF_BYTES } + } + + fn charge(&mut self, bytes: usize) -> Result<(), DataError> { + self.bytes = self.bytes.checked_sub(bytes).ok_or(DataError::ExportLimit("64 MiB data"))?; + Ok(()) + } + + fn slot(&mut self) -> Result<(usize, u32), DataError> { + if self.objects.len() >= MAX_FDF_OBJECTS { + return Err(DataError::ExportLimit("100,000 objects")); + } + // Object 1 is the FDF root; arena indices start at object 2. The arena is capped above. + let index = self.objects.len(); + let number = index as u32 + 2; + self.charge(100)?; // indirect-object and xref overhead + self.objects.push(Object::Null); + Ok((index, number)) + } + + fn dict(&mut self, dict: &Dict, depth: usize) -> Result { + if dict.len() > self.nodes { + return Err(DataError::ExportLimit("100,000 values")); + } + let mut out = Dict::new(); + for (key, value) in dict.iter() { + self.dict_entry(&mut out, key, value, depth + 1)?; + } + Ok(out) + } + + fn dict_entry(&mut self, out: &mut Dict, key: &[u8], value: &Object, depth: usize) -> Result<(), DataError> { + self.charge(key.len().saturating_mul(3).saturating_add(2))?; + let value = self.copy(value, depth)?; + out.set(key.to_vec(), value); + Ok(()) + } + + /// Skip source-document links and appearances without cloning their values. The + /// dictionary and every retained entry are charged before any owned copy is made. + fn annotation_dict(&mut self, dict: &Dict) -> Result { + self.charge_value(0)?; + let mut out = Dict::new(); + for (key, value) in dict.iter() { + if matches!(key.as_slice(), b"P" | b"AP" | b"Popup" | b"IRT" | b"Parent" | b"FS" | b"Sound") { + continue; + } + self.dict_entry(&mut out, key, value, 1)?; + } + Ok(out) + } + + fn charge_value(&mut self, depth: usize) -> Result<(), DataError> { + if depth > MAX_DEPTH { + return Err(DataError::ExportLimit("64 direct nesting levels")); + } + self.nodes = self.nodes.checked_sub(1).ok_or(DataError::ExportLimit("100,000 values"))?; + // Includes delimiters, spacing and even the longest finite f64 in plain decimal form. + self.charge(330)?; + Ok(()) + } + + fn is_source_page(&self, dict: &Dict) -> Result { + let Some(value) = dict.get(b"Type") else { return Ok(false) }; + let mut value = FdfValue::Borrowed(value); + let mut seen = HashSet::new(); + for hops in 0..=MAX_DEPTH { + match value.object() { + Object::Name(name) => return Ok(matches!(name.as_slice(), b"Page" | b"Pages")), + Object::Ref(reference) => { + if self.doc.generation(reference.num) != reference.generation || !seen.insert(*reference) { + return Ok(false); + } + if hops == MAX_DEPTH { + return Err(DataError::ExportLimit("64 Type reference levels")); + } + value = FdfValue::Indirect(self.doc.get(*reference)); + } + _ => return Ok(false), + } + } + Err(DataError::ExportLimit("64 Type reference levels")) + } + + fn copy(&mut self, value: &Object, depth: usize) -> Result { + self.charge_value(depth)?; + Ok(match value { + Object::Ref(source) => { + let number = if let Some(number) = self.numbers.get(source) { + *number + } else { + let (index, number) = self.slot()?; + self.numbers.insert(*source, number); + self.pending.push((*source, index)); + number + }; + Object::Ref(ObjRef::new(number, 0)) + } + Object::Array(values) => { + if values.len() > self.nodes { + return Err(DataError::ExportLimit("100,000 values")); + } + let mut out = Vec::new(); + for value in values { + out.push(self.copy(value, depth + 1)?); + } + Object::Array(out) + } + // Source page links must not drag page contents or the page tree into an FDF. + Object::Dict(dict) if self.is_source_page(dict)? => Object::Null, + Object::Dict(dict) => Object::Dict(self.dict(dict, depth)?), + Object::String(s) => { + self.charge(s.bytes.len().saturating_mul(4))?; + value.clone() + } + Object::Name(name) => { + self.charge(name.len().saturating_mul(3))?; + value.clone() + } + // A PDF stream can only be an indirect object, never a dictionary/array value. + Object::Stream(_) => return Err(DataError::Malformed("FDF export encountered a direct stream".into())), + other => other.clone(), + }) + } + + fn finish(&mut self, doc: &Document) -> Result<(), DataError> { + let mut cursor = 0; + while let Some((source, index)) = self.pending.get(cursor).copied() { + // ISO 32000-2 §7.3.10: an absent or stale reference denotes null. Keep reader + // errors distinct so damaged objects cannot be silently lost during export. + let value = if doc.generation(source.num) != source.generation { + std::sync::Arc::new(Object::Null) + } else { + match doc.try_get(source.num) { + Ok(value) => value, + Err(pdfcraft_cos::CosError::MissingObject(_)) => std::sync::Arc::new(Object::Null), + Err(error) => return Err(error.into()), + } + }; + let copied = match &*value { + Object::Stream(stream) => { + self.charge(stream.raw.len())?; + Object::Stream(pdfcraft_cos::Stream { dict: self.dict(&stream.dict, 0)?, raw: stream.raw.clone() }) + } + other => self.copy(other, 0)?, + }; + let slot = self.objects.get_mut(index).ok_or_else(|| DataError::Malformed("FDF object slot is missing".into()))?; + *slot = copied; + cursor += 1; + } + Ok(()) + } +} + +/// FDF with field values and/or comments (appearances are not carried). Each comment is an +/// indirect object of the FDF, so a reply's `/IRT` can point at its parent's object there +/// (§12.7.8.3.1). Nested values are copied and renumbered, including shared or cyclic graphs. +/// Source `/Page` and `/Pages` dictionaries become null at every copy depth. +/// Refuses exports above the object, value, direct-depth or data budget instead of losing data. +pub fn export_fdf(doc: &Document, comments: bool, fields: bool, file: &str) -> Result, DataError> { + let mut copy = FdfCopy::new(doc); + copy.charge(file.len().saturating_mul(8))?; + let mut body: Vec = b"<< /FDF << ".to_vec(); + // Comment dictionaries, written after the root as objects 2, 3, … + if fields { + body.extend_from_slice(b"/Fields ["); + for f in pdfcraft_forms::fields(doc) { + if matches!(f.kind, FieldKind::PushButton | FieldKind::Signature) { + continue; + } + // Field dictionary, /T string and /V scalar/array, plus every emitted list + // entry. Other field kinds emit only their first value (or a default). + let field_nodes = if matches!(f.kind, FieldKind::List) && f.value.len() > 1 { f.value.len().saturating_add(3) } else { 3 }; + copy.nodes = copy.nodes.checked_sub(field_nodes).ok_or(DataError::ExportLimit("100,000 values"))?; + copy.charge(f.name.len().saturating_mul(8).saturating_add(100))?; + for value in &f.value { + copy.charge(value.len().saturating_mul(8).saturating_add(100))?; + } + body.extend_from_slice(b"<< /T "); + body.extend(pdf_string(&f.name)); + body.extend_from_slice(b" /V "); + match f.kind { + FieldKind::CheckBox | FieldKind::Radio => { + let v = f.export_values().first().cloned().unwrap_or_else(|| "Off".into()); + pdfcraft_cos::serialize(&Object::Name(pdfcraft_forms::name_bytes(&v)), &mut body); + } + FieldKind::List if f.value.len() > 1 => { + pdfcraft_cos::serialize(&Object::Array(f.value.iter().map(|v| Object::String(PdfString::text(v))).collect()), &mut body); + } + _ => body.extend(pdf_string(f.value.first().map(String::as_str).unwrap_or(""))), + } + body.extend_from_slice(b" >> "); + } + body.extend_from_slice(b"] "); + } + if comments { + // Keep the page/array owners once. Handles retain positions, never annotation + // bodies: the document cache can evict and reparse even a repeated source reference. + let pages = pdfcraft_model::pages(doc); + let mut arrays = Vec::new(); + let mut picked: Vec<(Option, usize, usize)> = Vec::new(); + for (pi, page) in pages.iter().enumerate() { + let owner = page.dict.get(b"Annots").map(|value| FdfValue::resolve(doc, value)); + let before = picked.len(); + if let Some(values) = owner.as_ref().and_then(|owner| owner.object().as_array()) { + for (ai, a) in values.iter().enumerate() { + let value = FdfValue::resolve(doc, a); + let Some(d) = value.object().as_dict() else { continue }; + if d.name(b"Subtype").is_some_and(|sub| SUBTYPES.iter().any(|(s, _)| s.as_bytes() == sub)) { + if picked.len() >= MAX_FDF_OBJECTS { + return Err(DataError::ExportLimit("100,000 comments")); + } + picked.push((a.as_ref(), pi, ai)); + } + } + } + // Empty/unselected arrays need no owner. Resolve subsequent pages only after + // this page passes the cap, rather than loading them all before discovery. + arrays.push(if picked.len() > before { owner } else { None }); + } + let mut numbers = HashMap::new(); + for (source, _, _) in &picked { + let (_, number) = copy.slot()?; + if let Some(source) = source { + numbers.insert(*source, number); + } + } + copy.numbers = numbers.clone(); + let count = picked.len(); + for (index, (_, pi, ai)) in picked.into_iter().enumerate() { + let annotation = arrays + .get(pi) + .and_then(Option::as_ref) + .and_then(|owner| owner.object().as_array()) + .and_then(|values| values.get(ai)) + .ok_or_else(|| DataError::Malformed("FDF annotation slot is missing".into()))?; + let value = FdfValue::resolve(doc, annotation); + let d = value.object().as_dict().ok_or_else(|| DataError::Malformed("FDF comment is not a dictionary".into()))?; + // A reply points at its parent's object in this FDF; a parent that isn't exported + // leaves the reply unthreaded. + let irt = d.get(b"IRT").and_then(Object::as_ref).and_then(|r| numbers.get(&r)).copied(); + let mut d = copy.annotation_dict(d)?; + if let Some(num) = irt { + d.set(b"IRT".to_vec(), Object::Ref(ObjRef::new(num, 0))); + } + d.set(b"Page".to_vec(), Object::Int(pi as i64)); + let slot = copy.objects.get_mut(index).ok_or_else(|| DataError::Malformed("FDF comment slot is missing".into()))?; + *slot = Object::Dict(d); + } + body.extend_from_slice(b"/Annots ["); + for i in 0..count { + let _ = write!(body, "{} 0 R ", i + 2); + } + body.extend_from_slice(b"] "); + } + body.extend_from_slice(b"/F "); + body.extend(pdf_string(file)); + body.extend_from_slice(b" >> >>"); + copy.finish(doc)?; + let mut out = b"%FDF-1.2\n%\xE2\xE3\xCF\xD3\n".to_vec(); + let mut offsets = Vec::with_capacity(copy.objects.len() + 1); + offsets.push(out.len()); + out.extend_from_slice(b"1 0 obj\n"); + out.extend(body); + out.extend_from_slice(b"\nendobj\n"); + for (i, object) in copy.objects.into_iter().enumerate() { + offsets.push(out.len()); + let _ = writeln!(out, "{} 0 obj", i + 2); + pdfcraft_cos::serialize(&object, &mut out); + out.extend_from_slice(b"\nendobj\n"); + } + let xref = out.len(); + let _ = write!(out, "xref\n0 {}\n0000000000 65535 f \n", offsets.len() + 1); + for o in offsets { + let _ = writeln!(out, "{o:010} 00000 n "); + } + let _ = write!(out, "trailer\n<< /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n"); + Ok(out) +} + +/// What one FDF import may copy out of the file. A field tree or comment that repeats a reference +/// level after level would otherwise expand a small file into millions of values. +const MAX_FDF_IMPORT_VALUES: usize = 1_000_000; + +pub(crate) struct ImportBudget { + values: usize, + bytes: usize, +} + +impl ImportBudget { + pub(crate) fn new() -> Self { + Self { values: MAX_FDF_IMPORT_VALUES, bytes: MAX_FDF_BYTES } + } + + fn spend(&mut self, values: usize, bytes: usize) -> Result<(), DataError> { + match (self.values.checked_sub(values), self.bytes.checked_sub(bytes)) { + (Some(v), Some(b)) => { + (self.values, self.bytes) = (v, b); + Ok(()) + } + _ => Err(DataError::Malformed(format!( + "the file expands to more than {MAX_FDF_IMPORT_VALUES} values or {} MiB of text", + MAX_FDF_BYTES >> 20 + ))), + } + } +} + +/// Copy an object out of another document, resolving references. A reference back to an object +/// being copied becomes null. +fn deep(src: &Document, o: &Object, depth: usize, path: &mut Vec, budget: &mut ImportBudget) -> Result { + budget.spend(1, 0)?; + if depth > 16 { + return Ok(Object::Null); + } + Ok(match o { + Object::Ref(r) => { + if path.contains(r) { + return Ok(Object::Null); + } + path.push(*r); + let copy = deep(src, &src.get(*r), depth + 1, path, budget); + path.pop(); + copy? + } + Object::Array(a) => Object::Array(a.iter().map(|x| deep(src, x, depth + 1, path, budget)).collect::>()?), + Object::Dict(d) => { + Object::Dict(d.iter().map(|(k, v)| Ok((k.clone(), deep(src, v, depth + 1, path, budget)?))).collect::>()?) + } + Object::Stream(_) => Object::Null, + Object::String(s) => { + budget.spend(0, s.bytes.len())?; + o.clone() + } + Object::Name(n) => { + budget.spend(0, n.len())?; + o.clone() + } + other => other.clone(), + }) +} + +/// Read an FDF field tree. A field listed again beneath itself is skipped. +pub(crate) fn walk_fdf( + fdf: &Document, + list: &[Object], + prefix: &str, + out: &mut Vec<(String, Vec)>, + depth: usize, + path: &mut Vec, + budget: &mut ImportBudget, +) -> Result<(), DataError> { + if depth > 32 { + return Ok(()); + } + for f in list { + budget.spend(1, 0)?; + let held; + let field = match f { + Object::Ref(r) if path.contains(r) => continue, + Object::Ref(r) => { + held = fdf.get(*r); + &*held + } + direct => direct, + }; + let Some(d) = field.as_dict() else { continue }; + let Some(t) = d.get(b"T").and_then(|t| fdf.resolve(t).as_string().map(|s| s.to_text())) else { continue }; + let full = if prefix.is_empty() { t } else { format!("{prefix}.{t}") }; + budget.spend(0, full.len())?; + if let Some(v) = d.get(b"V").map(|v| fdf.resolve(v)) { + let vals: Vec = match &*v { + Object::String(s) => vec![s.to_text()], + Object::Name(n) => vec![pdfcraft_forms::name_text(n)], + Object::Array(a) => { + budget.spend(a.len(), 0)?; + a.iter().filter_map(|x| fdf.resolve(x).as_string().map(|s| s.to_text())).collect() + } + _ => Vec::new(), + }; + budget.spend(vals.len(), vals.iter().map(String::len).fold(0, usize::saturating_add))?; + out.push((full.clone(), vals)); + } + if let Some(kids) = d.get(b"Kids").map(|k| fdf.resolve(k)) + && let Some(kids) = kids.as_array() + { + let entered = f.as_ref().map(|r| path.push(r)).is_some(); + let walked = walk_fdf(fdf, kids, &full, out, depth + 1, path, budget); + if entered { + path.pop(); + } + walked?; + } + } + Ok(()) +} + +pub(crate) fn fdf_body(fdf: &Document) -> Result { + let root = fdf.root().ok_or_else(|| DataError::Malformed("no /Root".into()))?; + fdf.get(root) + .as_dict() + .and_then(|d| d.get(b"FDF").cloned()) + .map(|f| fdf.resolve(&f)) + .and_then(|f| f.as_dict().cloned()) + .ok_or(DataError::UnknownFormat) +} + +pub(crate) fn import_fdf(doc: &mut Document, bytes: &[u8]) -> Result { + let fdf = Document::open(std::sync::Arc::new(bytes.to_vec())).map_err(|e| DataError::Malformed(e.to_string()))?; + let body = fdf_body(&fdf)?; + let mut report = Report::default(); + let mut budget = ImportBudget::new(); + // Fields (with /Kids for hierarchical names). + let mut values = Vec::new(); + if let Some(list) = body.get(b"Fields").map(|f| fdf.resolve(f)).and_then(|f| f.as_array().cloned()) { + walk_fdf(&fdf, &list, "", &mut values, 0, &mut Vec::new(), &mut budget)?; + } + // Comments: parents first, then replies (whose /IRT needs the parent's new reference). + let pages: Vec = pdfcraft_model::pages(doc).iter().map(|p| p.obj).collect(); + let mut placed: HashMap = HashMap::new(); + let mut later: HashMap, Option)> = HashMap::new(); + for a in body.get(b"Annots").map(|a| fdf.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default() { + let Some(mut src) = fdf.resolve(&a).as_dict().cloned() else { continue }; + // The parent is another comment: link it below rather than copying it in. + let irt = src.get(b"IRT").cloned(); + src.remove(b"IRT"); + // The pop-up is an annotation of its own (/Popup shall be an indirect reference, ISO + // 32000-2 Table 172): copied in, it would become a direct dictionary. A new one is + // placed on the page below, as the XFDF import does. + let popup = src.get(b"Popup").map(|p| fdf.resolve(p)).and_then(|p| p.as_dict().cloned()); + src.remove(b"Popup"); + let mut path: Vec = a.as_ref().into_iter().collect(); + let Object::Dict(mut d) = deep(&fdf, &Object::Dict(src), 0, &mut path, &mut budget)? else { continue }; + let Some(page) = d.get(b"Page").and_then(Object::as_int).and_then(|p| usize::try_from(p).ok()).and_then(|p| pages.get(p).copied()) else { + continue; + }; + if !d.name(b"Subtype").is_some_and(|s| SUBTYPES.iter().any(|(x, _)| x.as_bytes() == s)) { + continue; + } + d.remove(b"Page"); + d.set(b"P".to_vec(), Object::Ref(page)); + let r = place(doc, page, d)?; + later.remove(&r); + if let Some(rect) = popup.as_ref().and_then(|p| rect_strict(&fdf, p.get(b"Rect"))) { + let open = popup.as_ref().and_then(|p| p.get(b"Open")).is_some_and(|o| matches!(&*fdf.resolve(o), Object::Bool(true))); + add_popup(doc, page, r, &rect, open)?; + } + if let Some(fr) = a.as_ref() { + placed.insert(fr, r); + } + if let Some(irt) = irt { + let parent_name = fdf.resolve(&irt).as_dict().and_then(|p| text_of(&fdf, p, b"NM")); + later.insert(r, (irt.as_ref(), parent_name)); + } + report.comments += 1; + } + for (r, (parent_obj, parent_name)) in later { + // The parent imported from this file, else one already in the document with its name. + let parent = parent_obj.and_then(|p| placed.get(&p).copied()).or_else(|| parent_name.and_then(|nm| find_by_name(doc, &nm))); + if let Some(parent) = parent.filter(|p| *p != r) { + doc.update_dict(r, |d| d.set(b"IRT".to_vec(), Object::Ref(parent)))?; + } + } + apply_values(doc, &values, &mut report); + Ok(report) +} diff --git a/crates/xfdf/src/lib.rs b/crates/xfdf/src/lib.rs index 9c3d9ab017..16c554d72f 100644 --- a/crates/xfdf/src/lib.rs +++ b/crates/xfdf/src/lib.rs @@ -18,6 +18,22 @@ use std::io::Write as _; use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString}; use pdfcraft_forms::{FieldKind, FieldValue}; +mod common; +mod fdf; +mod xfdf; + +#[cfg(test)] +use common::{MAX_DEPTH, MAX_XML_DEPTH, arr, nums_of, parse_hex, text_of, xml_nesting_depth}; +use common::{check_form_xml_depth, esc, parse_form_xml, walk_xfdf, xml_name}; +pub use fdf::export_fdf; +use fdf::{ImportBudget, fdf_body, import_fdf, walk_fdf}; +#[cfg(test)] +use fdf::{MAX_FDF_BYTES, MAX_FDF_OBJECTS}; +pub use xfdf::export_xfdf; +#[cfg(test)] +use xfdf::{LOCKED, find_by_name}; +use xfdf::{apply_values, import_xfdf}; + #[cfg(test)] mod tests; @@ -71,774 +87,6 @@ pub struct Report { pub rejected: Vec, } -// ── names ─────────────────────────────────────────────────────────────────────────────────── - -/// Form data files are attacker-supplied. Refuse a DTD and bound the node count, exactly as the -/// XFA template parser does (`crates/xfa/src/parse.rs`), and bound how deep a nested field tree -/// may go: the walk below recurses once per nesting level. -const MAX_NODES: u32 = 2_000_000; -const MAX_DEPTH: usize = 64; - -/// The deepest element nesting accepted before the text reaches the XML parser. -/// -/// roxmltree parses by recursive descent, so its stack use grows with the nesting depth: ten -/// thousand levels overflow the main thread's stack and abort the process. A stack overflow is -/// `abort()`, so `catch_unwind` cannot intercept it and the whole application dies - which is why -/// the bound has to be applied before parsing, not while walking the result. Around three thousand -/// levels still parse on the default stack, so this stays clear of the cliff while leaving far more -/// room than the walks below, which cut off at 64. -const MAX_XML_DEPTH: usize = 1_024; - -/// Element nesting depth of `text`, skipping comments, CDATA, processing instructions, the DOCTYPE -/// and quoted attribute values. A tag that closes itself (``) is one level while it is read -/// and does not stay open, so a long run of them cannot accumulate into the bound. It cannot -/// under-count a real element; a stray `<` in text is invalid XML and only makes it over-count, -/// which errs towards refusing the file. -fn xml_nesting_depth(text: &str) -> usize { - fn find(hay: &[u8], from: usize, needle: &[u8]) -> Option { - (from..hay.len()).find(|&p| hay[p..].starts_with(needle)) - } - let b = text.as_bytes(); - let (mut i, mut depth, mut max) = (0usize, 0usize, 0usize); - while i < b.len() { - if b[i] != b'<' { - i += 1; - } else if b[i..].starts_with(b"").map_or(b.len(), |p| p + 3); - } else if b[i..].starts_with(b"").map_or(b.len(), |p| p + 3); - } else if b[i..].starts_with(b"").map_or(b.len(), |p| p + 2); - } else if b[i..].starts_with(b"").map_or(b.len(), |p| p + 1); - } else if b[i..].starts_with(b"").map_or(b.len(), |p| p + 1); - } else { - depth += 1; - max = max.max(depth); - // Skip the rest of the start tag, over any quoted attribute value, and remember its last - // significant byte: a tag that ends in `/` closes itself, so it is one level deep while it - // is read rather than a level left open. - i += 1; - let mut quote = 0u8; - let mut last = 0u8; - while i < b.len() { - let c = b[i]; - if quote != 0 { - if c == quote { - quote = 0; - } - } else if c == b'"' || c == b'\'' { - quote = c; - } else if c == b'>' { - i += 1; - break; - } else if !c.is_ascii_whitespace() { - last = c; - } - i += 1; - } - if last == b'/' { - depth -= 1; - } - } - } - max -} - -fn check_form_xml_depth(text: &str) -> Result<(), DataError> { - let depth = xml_nesting_depth(text); - if depth > MAX_XML_DEPTH { - return Err(DataError::Malformed(format!("the file nests XML elements {depth} deep, past the {MAX_XML_DEPTH} the reader accepts"))); - } - Ok(()) -} - -fn parse_form_xml(text: &str) -> Result, roxmltree::Error> { - roxmltree::Document::parse_with_options(text, roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: MAX_NODES }) -} - -const SUBTYPES: [(&str, &str); 17] = [ - ("Text", "text"), - ("FreeText", "freetext"), - ("Line", "line"), - ("Square", "square"), - ("Circle", "circle"), - ("Polygon", "polygon"), - ("PolyLine", "polyline"), - ("Highlight", "highlight"), - ("Underline", "underline"), - ("Squiggly", "squiggly"), - ("StrikeOut", "strikeout"), - ("Stamp", "stamp"), - ("Caret", "caret"), - ("Ink", "ink"), - ("FileAttachment", "fileattachment"), - ("Sound", "sound"), - ("Redact", "redact"), -]; - -const FLAGS: [(i64, &str); 9] = [ - (1, "invisible"), - (2, "hidden"), - (4, "print"), - (8, "nozoom"), - (16, "norotate"), - (32, "noview"), - (64, "readonly"), - (128, "locked"), - (256, "togglenoview"), -]; - -fn esc(s: &str) -> String { - let mut o = String::with_capacity(s.len()); - for c in s.chars() { - match c { - '&' => o.push_str("&"), - '<' => o.push_str("<"), - '>' => o.push_str(">"), - '"' => o.push_str("""), - // XML 1.0 §3.3.3 normalizes literal attribute whitespace, but not references. - '\t' => o.push_str(" "), - '\n' => o.push_str(" "), - '\r' => o.push_str(" "), - c if (c as u32) < 0x20 && c != '\n' && c != '\t' => {} - // XML 1.0 noncharacters are not allowed even as raw text. - '\u{FFFE}' | '\u{FFFF}' => {} - c => o.push(c), - } - } - o -} - -fn n(v: f64) -> String { - let s = format!("{v:.4}"); - let s = s.trim_end_matches('0').trim_end_matches('.'); - if s == "-0" { "0".into() } else { s.into() } -} - -fn hex(c: &[f64]) -> Option { - let rgb = match c.len() { - 1 => [c[0]; 3], - 3 => [c[0], c[1], c[2]], - 4 => [(1.0 - c[0]) * (1.0 - c[3]), (1.0 - c[1]) * (1.0 - c[3]), (1.0 - c[2]) * (1.0 - c[3])], - _ => return None, - }; - Some(format!( - "#{:02X}{:02X}{:02X}", - (rgb[0].clamp(0.0, 1.0) * 255.0).round() as u8, - (rgb[1].clamp(0.0, 1.0) * 255.0).round() as u8, - (rgb[2].clamp(0.0, 1.0) * 255.0).round() as u8 - )) -} - -fn parse_hex(s: &str) -> Option<[f64; 3]> { - let h = s.trim().trim_start_matches('#'); - if h.len() != 6 || !h.bytes().all(|b| b.is_ascii_hexdigit()) { - return None; - } - let p = |i: usize| h.get(i..i + 2).and_then(|s| u8::from_str_radix(s, 16).ok()).map(|v| f64::from(v) / 255.0); - Some([p(0)?, p(2)?, p(4)?]) -} - -fn nums_of(doc: &Document, o: Option<&Object>) -> Vec { - o.map(|o| doc.resolve(o)).and_then(|a| a.as_array().map(|a| a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect())).unwrap_or_default() -} - -/// A `/Rect` of exactly four finite numbers; any other entry makes the whole rectangle unusable. -fn rect_strict(doc: &Document, o: Option<&Object>) -> Option<[f64; 4]> { - let num = |x: &Object| doc.resolve(x).as_f64().filter(|v| v.is_finite()); - match doc.resolve(o?).as_array()?.as_slice() { - [x0, y0, x1, y1] => Some([num(x0)?, num(y0)?, num(x1)?, num(y1)?]), - _ => None, - } -} - -/// Whether a quad's four corners enclose any area: some three of them are not collinear. -fn quad_has_area(q: &[f64; 8]) -> bool { - let [ax, ay, bx, by, cx, cy, dx, dy] = *q; - let cross = |(px, py): (f64, f64), (qx, qy): (f64, f64), (rx, ry): (f64, f64)| (qx - px) * (ry - py) - (qy - py) * (rx - px); - let (a, b, c, d) = ((ax, ay), (bx, by), (cx, cy), (dx, dy)); - [cross(a, b, c), cross(a, b, d), cross(a, c, d), cross(b, c, d)].iter().any(|v| v.abs() > 0.0) -} - -fn text_of(doc: &Document, d: &Dict, k: &[u8]) -> Option { - d.get(k).and_then(|o| match &*doc.resolve(o) { - Object::String(s) => Some(s.to_text()), - Object::Name(n) => Some(pdfcraft_forms::name_text(n)), - _ => None, - }) -} - -/// The numbers of a list; empty when any entry is non-finite (`nan`, `inf`), so the list is unusable as a whole. -fn csv(list: &str) -> Vec { - let v: Vec = list.split([',', ';', ' ']).filter(|t| !t.is_empty()).filter_map(|t| t.trim().parse().ok()).collect(); - if v.iter().all(|x| x.is_finite()) { v } else { Vec::new() } -} - -fn arr(v: &[f64]) -> Object { - Object::Array(v.iter().map(|x| Object::Real(*x)).collect()) -} - -// ── export ────────────────────────────────────────────────────────────────────────────────── - -/// Every comment as an XFDF element (one line per comment, replies included). -fn xfdf_annots(doc: &Document, out: &mut String) { - let pages = pdfcraft_model::pages(doc); - // NM of every annotation, for inreplyto. - let mut names: HashMap = HashMap::new(); - for p in &pages { - for a in p.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default() { - if let Some(r) = a.as_ref() - && let Some(nm) = doc.get(r).as_dict().and_then(|d| text_of(doc, d, b"NM")) - { - names.insert(r, nm); - } - } - } - for (pi, p) in pages.iter().enumerate() { - for a in p.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default() { - let Some(d) = doc.resolve(&a).as_dict().cloned() else { continue }; - let Some(sub) = d.name(b"Subtype") else { continue }; - let Some((_, tag)) = SUBTYPES.iter().find(|(s, _)| s.as_bytes() == sub) else { continue }; - let mut attrs = format!(" page=\"{pi}\""); - let rect = nums_of(doc, d.get(b"Rect")); - if rect.len() == 4 { - let _ = write!( - attrs, - " rect=\"{},{},{},{}\"", - n(rect[0].min(rect[2])), - n(rect[1].min(rect[3])), - n(rect[0].max(rect[2])), - n(rect[1].max(rect[3])) - ); - } - for (key, attr) in [ - (&b"NM"[..], "name"), - (b"T", "title"), - (b"Subj", "subject"), - (b"M", "date"), - (b"CreationDate", "creationdate"), - (b"RT", "replyType"), - (b"State", "state"), - (b"StateModel", "statemodel"), - (b"Name", "icon"), - ] { - if let Some(v) = text_of(doc, &d, key) { - let _ = write!(attrs, " {attr}=\"{}\"", esc(&v)); - } - } - let flags = d.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0); - let fl: Vec<&str> = FLAGS.iter().filter(|(b, _)| flags & b != 0).map(|(_, s)| *s).collect(); - let _ = write!(attrs, " flags=\"{}\"", fl.join(",")); - if let Some(c) = hex(&nums_of(doc, d.get(b"C"))) { - let _ = write!(attrs, " color=\"{c}\""); - } - if let Some(c) = hex(&nums_of(doc, d.get(b"IC"))) { - let _ = write!(attrs, " interior-color=\"{c}\""); - } - if let Some(o) = d.get(b"CA").and_then(|o| doc.resolve(o).as_f64()) { - let _ = write!(attrs, " opacity=\"{}\"", n(o)); - } - if let Some(w) = d.get(b"BS").and_then(|b| doc.resolve(b).as_dict().and_then(|b| b.get(b"W").and_then(Object::as_f64))) { - let _ = write!(attrs, " width=\"{}\"", n(w)); - } - let quads = nums_of(doc, d.get(b"QuadPoints")); - if !quads.is_empty() { - let _ = write!(attrs, " coords=\"{}\"", quads.iter().map(|v| n(*v)).collect::>().join(",")); - } - let l = nums_of(doc, d.get(b"L")); - if l.len() == 4 { - let _ = write!(attrs, " start=\"{},{}\" end=\"{},{}\"", n(l[0]), n(l[1]), n(l[2]), n(l[3])); - } - if let Some(le) = d.get(b"LE").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()) - && le.len() == 2 - { - let name = |o: &Object| o.as_name().map(|x| String::from_utf8_lossy(x).into_owned()).unwrap_or_else(|| "None".into()); - let _ = write!(attrs, " head=\"{}\" tail=\"{}\"", esc(&name(&le[0])), esc(&name(&le[1]))); - } - // A callout: its leader path, text-box inset, intent and (single-name) line ending. - let cl = nums_of(doc, d.get(b"CL")); - if cl.len() == 4 || cl.len() == 6 { - let _ = write!(attrs, " callout=\"{}\"", cl.iter().map(|v| n(*v)).collect::>().join(",")); - let rd = nums_of(doc, d.get(b"RD")); - if rd.len() == 4 { - let _ = write!(attrs, " fringe=\"{}\"", rd.iter().map(|v| n(*v)).collect::>().join(",")); - } - if let Some(it) = d.get(b"IT").map(|o| doc.resolve(o)).and_then(|o| o.as_name().map(|x| String::from_utf8_lossy(x).into_owned())) { - let _ = write!(attrs, " intent=\"{}\"", esc(&it)); - } - if let Some(le) = d.get(b"LE").map(|o| doc.resolve(o)).and_then(|o| o.as_name().map(|x| String::from_utf8_lossy(x).into_owned())) { - let _ = write!(attrs, " head=\"{}\"", esc(&le)); - } - } - if let Some(irt) = d.get(b"IRT").and_then(Object::as_ref).and_then(|r| names.get(&r)) { - let _ = write!(attrs, " inreplyto=\"{}\"", esc(irt)); - } - let _ = write!(out, "<{tag}{attrs}>"); - if let Some(c) = text_of(doc, &d, b"Contents") { - let _ = write!(out, "{}", esc(&c)); - } - if let Some(da) = text_of(doc, &d, b"DA") { - let _ = write!(out, "{}", esc(&da)); - } - let ink = d.get(b"InkList").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); - if !ink.is_empty() { - out.push_str(""); - for g in ink { - let v = nums_of(doc, Some(&g)); - let pts: Vec = v.as_chunks::<2>().0.iter().map(|p| format!("{},{}", n(p[0]), n(p[1]))).collect(); - let _ = write!(out, "{}", pts.join(";")); - } - out.push_str(""); - } - // Polygon and polyline points (XFDF ``: `x,y;x,y;…`). - let vertices = nums_of(doc, d.get(b"Vertices")); - let pts: Vec = vertices.as_chunks::<2>().0.iter().map(|p| format!("{},{}", n(p[0]), n(p[1]))).collect(); - if !pts.is_empty() { - let _ = write!(out, "{}", pts.join(";")); - } - if let Some(pd) = d.get(b"Popup").map(|p| doc.resolve(p)).and_then(|p| p.as_dict().cloned()) { - let r = nums_of(doc, pd.get(b"Rect")); - if r.len() == 4 { - let open = matches!(pd.get(b"Open"), Some(Object::Bool(true))); - let _ = write!( - out, - "", - n(r[0]), - n(r[1]), - n(r[2]), - n(r[3]), - if open { "yes" } else { "no" } - ); - } - } - let _ = writeln!(out, ""); - } - } -} - -/// Field values as nested XFDF `` elements. -fn xfdf_fields(doc: &Document, out: &mut String) { - #[derive(Default)] - struct Node { - values: Vec, - kids: Vec<(String, Node)>, - } - let mut root = Node::default(); - for f in pdfcraft_forms::fields(doc) { - if matches!(f.kind, FieldKind::PushButton | FieldKind::Signature) { - continue; - } - let mut node = &mut root; - // One `` level per part of the name, but no more levels than the import reads - // back. A `/T` can hold periods of its own, so a name can split into far more parts than - // the field tree has levels: nesting every part wrote a file the import refuses and, for - // a long enough name, recursed in `write_node` until the stack overflowed. The parts past - // the last level stay together in the deepest element's name, and the import joins them - // back with periods into the same full name. - for part in f.name.splitn(MAX_DEPTH, '.') { - let i = match node.kids.iter().position(|(k, _)| k == part) { - Some(i) => i, - None => { - node.kids.push((part.to_string(), Node::default())); - node.kids.len() - 1 - } - }; - node = &mut node.kids[i].1; - } - node.values = match f.kind { - FieldKind::CheckBox | FieldKind::Radio if f.value.is_empty() => vec!["Off".into()], - _ => f.value.clone(), - }; - } - fn write_node(name: &str, node: &Node, out: &mut String) { - let _ = write!(out, "", esc(name)); - for v in &node.values { - let _ = write!(out, "{}", esc(v)); - } - for (k, kid) in &node.kids { - write_node(k, kid, out); - } - out.push_str("\n"); - } - for (k, kid) in &root.kids { - write_node(k, kid, out); - } -} - -/// XFDF with the document's comments and/or field values. -pub fn export_xfdf(doc: &Document, comments: bool, fields: bool, file: &str) -> String { - let mut out = String::from("\n\n"); - if comments { - out.push_str("\n"); - xfdf_annots(doc, &mut out); - out.push_str("\n"); - } - if fields { - out.push_str("\n"); - xfdf_fields(doc, &mut out); - out.push_str("\n"); - } - let _ = writeln!(out, "", esc(file)); - out.push_str("\n"); - out -} - -fn pdf_string(s: &str) -> Vec { - let mut v = Vec::new(); - pdfcraft_cos::serialize(&Object::String(PdfString::text(s)), &mut v); - v -} - -// Keep aliases as aliases instead of expanding a shared graph once per use. References are -// queued, not followed recursively, so cycles and long indirect chains cost linear work. -const MAX_FDF_OBJECTS: usize = 100_000; -const MAX_FDF_BYTES: usize = 64 * 1024 * 1024; - -/// Direct values stay borrowed: `Document::resolve` would deep-clone them. Only -/// indirect values need an owner, and annotation bodies are never retained in discovery. -enum FdfValue<'a> { - Borrowed(&'a Object), - Indirect(std::sync::Arc), -} - -impl<'a> FdfValue<'a> { - fn resolve(doc: &Document, value: &'a Object) -> Self { - match value { - Object::Ref(_) => Self::Indirect(doc.resolve(value)), - _ => Self::Borrowed(value), - } - } - - fn object(&self) -> &Object { - match self { - Self::Borrowed(value) => value, - Self::Indirect(value) => value.as_ref(), - } - } -} - -struct FdfCopy<'a> { - doc: &'a Document, - objects: Vec, - numbers: HashMap, - pending: Vec<(ObjRef, usize)>, - nodes: usize, - bytes: usize, -} - -impl<'a> FdfCopy<'a> { - fn new(doc: &'a Document) -> Self { - Self { doc, objects: Vec::new(), numbers: HashMap::new(), pending: Vec::new(), nodes: MAX_FDF_OBJECTS, bytes: MAX_FDF_BYTES } - } - - fn charge(&mut self, bytes: usize) -> Result<(), DataError> { - self.bytes = self.bytes.checked_sub(bytes).ok_or(DataError::ExportLimit("64 MiB data"))?; - Ok(()) - } - - fn slot(&mut self) -> Result<(usize, u32), DataError> { - if self.objects.len() >= MAX_FDF_OBJECTS { - return Err(DataError::ExportLimit("100,000 objects")); - } - // Object 1 is the FDF root; arena indices start at object 2. The arena is capped above. - let index = self.objects.len(); - let number = index as u32 + 2; - self.charge(100)?; // indirect-object and xref overhead - self.objects.push(Object::Null); - Ok((index, number)) - } - - fn dict(&mut self, dict: &Dict, depth: usize) -> Result { - if dict.len() > self.nodes { - return Err(DataError::ExportLimit("100,000 values")); - } - let mut out = Dict::new(); - for (key, value) in dict.iter() { - self.dict_entry(&mut out, key, value, depth + 1)?; - } - Ok(out) - } - - fn dict_entry(&mut self, out: &mut Dict, key: &[u8], value: &Object, depth: usize) -> Result<(), DataError> { - self.charge(key.len().saturating_mul(3).saturating_add(2))?; - let value = self.copy(value, depth)?; - out.set(key.to_vec(), value); - Ok(()) - } - - /// Skip source-document links and appearances without cloning their values. The - /// dictionary and every retained entry are charged before any owned copy is made. - fn annotation_dict(&mut self, dict: &Dict) -> Result { - self.charge_value(0)?; - let mut out = Dict::new(); - for (key, value) in dict.iter() { - if matches!(key.as_slice(), b"P" | b"AP" | b"Popup" | b"IRT" | b"Parent" | b"FS" | b"Sound") { - continue; - } - self.dict_entry(&mut out, key, value, 1)?; - } - Ok(out) - } - - fn charge_value(&mut self, depth: usize) -> Result<(), DataError> { - if depth > MAX_DEPTH { - return Err(DataError::ExportLimit("64 direct nesting levels")); - } - self.nodes = self.nodes.checked_sub(1).ok_or(DataError::ExportLimit("100,000 values"))?; - // Includes delimiters, spacing and even the longest finite f64 in plain decimal form. - self.charge(330)?; - Ok(()) - } - - fn is_source_page(&self, dict: &Dict) -> Result { - let Some(value) = dict.get(b"Type") else { return Ok(false) }; - let mut value = FdfValue::Borrowed(value); - let mut seen = HashSet::new(); - for hops in 0..=MAX_DEPTH { - match value.object() { - Object::Name(name) => return Ok(matches!(name.as_slice(), b"Page" | b"Pages")), - Object::Ref(reference) => { - if self.doc.generation(reference.num) != reference.generation || !seen.insert(*reference) { - return Ok(false); - } - if hops == MAX_DEPTH { - return Err(DataError::ExportLimit("64 Type reference levels")); - } - value = FdfValue::Indirect(self.doc.get(*reference)); - } - _ => return Ok(false), - } - } - Err(DataError::ExportLimit("64 Type reference levels")) - } - - fn copy(&mut self, value: &Object, depth: usize) -> Result { - self.charge_value(depth)?; - Ok(match value { - Object::Ref(source) => { - let number = if let Some(number) = self.numbers.get(source) { - *number - } else { - let (index, number) = self.slot()?; - self.numbers.insert(*source, number); - self.pending.push((*source, index)); - number - }; - Object::Ref(ObjRef::new(number, 0)) - } - Object::Array(values) => { - if values.len() > self.nodes { - return Err(DataError::ExportLimit("100,000 values")); - } - let mut out = Vec::new(); - for value in values { - out.push(self.copy(value, depth + 1)?); - } - Object::Array(out) - } - // Source page links must not drag page contents or the page tree into an FDF. - Object::Dict(dict) if self.is_source_page(dict)? => Object::Null, - Object::Dict(dict) => Object::Dict(self.dict(dict, depth)?), - Object::String(s) => { - self.charge(s.bytes.len().saturating_mul(4))?; - value.clone() - } - Object::Name(name) => { - self.charge(name.len().saturating_mul(3))?; - value.clone() - } - // A PDF stream can only be an indirect object, never a dictionary/array value. - Object::Stream(_) => return Err(DataError::Malformed("FDF export encountered a direct stream".into())), - other => other.clone(), - }) - } - - fn finish(&mut self, doc: &Document) -> Result<(), DataError> { - let mut cursor = 0; - while let Some((source, index)) = self.pending.get(cursor).copied() { - // ISO 32000-2 §7.3.10: an absent or stale reference denotes null. Keep reader - // errors distinct so damaged objects cannot be silently lost during export. - let value = if doc.generation(source.num) != source.generation { - std::sync::Arc::new(Object::Null) - } else { - match doc.try_get(source.num) { - Ok(value) => value, - Err(pdfcraft_cos::CosError::MissingObject(_)) => std::sync::Arc::new(Object::Null), - Err(error) => return Err(error.into()), - } - }; - let copied = match &*value { - Object::Stream(stream) => { - self.charge(stream.raw.len())?; - Object::Stream(pdfcraft_cos::Stream { dict: self.dict(&stream.dict, 0)?, raw: stream.raw.clone() }) - } - other => self.copy(other, 0)?, - }; - let slot = self.objects.get_mut(index).ok_or_else(|| DataError::Malformed("FDF object slot is missing".into()))?; - *slot = copied; - cursor += 1; - } - Ok(()) - } -} - -/// FDF with field values and/or comments (appearances are not carried). Each comment is an -/// indirect object of the FDF, so a reply's `/IRT` can point at its parent's object there -/// (§12.7.8.3.1). Nested values are copied and renumbered, including shared or cyclic graphs. -/// Source `/Page` and `/Pages` dictionaries become null at every copy depth. -/// Refuses exports above the object, value, direct-depth or data budget instead of losing data. -pub fn export_fdf(doc: &Document, comments: bool, fields: bool, file: &str) -> Result, DataError> { - let mut copy = FdfCopy::new(doc); - copy.charge(file.len().saturating_mul(8))?; - let mut body: Vec = b"<< /FDF << ".to_vec(); - // Comment dictionaries, written after the root as objects 2, 3, … - if fields { - body.extend_from_slice(b"/Fields ["); - for f in pdfcraft_forms::fields(doc) { - if matches!(f.kind, FieldKind::PushButton | FieldKind::Signature) { - continue; - } - // Field dictionary, /T string and /V scalar/array, plus every emitted list - // entry. Other field kinds emit only their first value (or a default). - let field_nodes = if matches!(f.kind, FieldKind::List) && f.value.len() > 1 { f.value.len().saturating_add(3) } else { 3 }; - copy.nodes = copy.nodes.checked_sub(field_nodes).ok_or(DataError::ExportLimit("100,000 values"))?; - copy.charge(f.name.len().saturating_mul(8).saturating_add(100))?; - for value in &f.value { - copy.charge(value.len().saturating_mul(8).saturating_add(100))?; - } - body.extend_from_slice(b"<< /T "); - body.extend(pdf_string(&f.name)); - body.extend_from_slice(b" /V "); - match f.kind { - FieldKind::CheckBox | FieldKind::Radio => { - let v = f.value.first().cloned().unwrap_or_else(|| "Off".into()); - pdfcraft_cos::serialize(&Object::Name(pdfcraft_forms::name_bytes(&v)), &mut body); - } - FieldKind::List if f.value.len() > 1 => { - pdfcraft_cos::serialize(&Object::Array(f.value.iter().map(|v| Object::String(PdfString::text(v))).collect()), &mut body); - } - _ => body.extend(pdf_string(f.value.first().map(String::as_str).unwrap_or(""))), - } - body.extend_from_slice(b" >> "); - } - body.extend_from_slice(b"] "); - } - if comments { - // Keep the page/array owners once. Handles retain positions, never annotation - // bodies: the document cache can evict and reparse even a repeated source reference. - let pages = pdfcraft_model::pages(doc); - let mut arrays = Vec::new(); - let mut picked: Vec<(Option, usize, usize)> = Vec::new(); - for (pi, page) in pages.iter().enumerate() { - let owner = page.dict.get(b"Annots").map(|value| FdfValue::resolve(doc, value)); - let before = picked.len(); - if let Some(values) = owner.as_ref().and_then(|owner| owner.object().as_array()) { - for (ai, a) in values.iter().enumerate() { - let value = FdfValue::resolve(doc, a); - let Some(d) = value.object().as_dict() else { continue }; - if d.name(b"Subtype").is_some_and(|sub| SUBTYPES.iter().any(|(s, _)| s.as_bytes() == sub)) { - if picked.len() >= MAX_FDF_OBJECTS { - return Err(DataError::ExportLimit("100,000 comments")); - } - picked.push((a.as_ref(), pi, ai)); - } - } - } - // Empty/unselected arrays need no owner. Resolve subsequent pages only after - // this page passes the cap, rather than loading them all before discovery. - arrays.push(if picked.len() > before { owner } else { None }); - } - let mut numbers = HashMap::new(); - for (source, _, _) in &picked { - let (_, number) = copy.slot()?; - if let Some(source) = source { - numbers.insert(*source, number); - } - } - copy.numbers = numbers.clone(); - let count = picked.len(); - for (index, (_, pi, ai)) in picked.into_iter().enumerate() { - let annotation = arrays - .get(pi) - .and_then(Option::as_ref) - .and_then(|owner| owner.object().as_array()) - .and_then(|values| values.get(ai)) - .ok_or_else(|| DataError::Malformed("FDF annotation slot is missing".into()))?; - let value = FdfValue::resolve(doc, annotation); - let d = value.object().as_dict().ok_or_else(|| DataError::Malformed("FDF comment is not a dictionary".into()))?; - // A reply points at its parent's object in this FDF; a parent that isn't exported - // leaves the reply unthreaded. - let irt = d.get(b"IRT").and_then(Object::as_ref).and_then(|r| numbers.get(&r)).copied(); - let mut d = copy.annotation_dict(d)?; - if let Some(num) = irt { - d.set(b"IRT".to_vec(), Object::Ref(ObjRef::new(num, 0))); - } - d.set(b"Page".to_vec(), Object::Int(pi as i64)); - let slot = copy.objects.get_mut(index).ok_or_else(|| DataError::Malformed("FDF comment slot is missing".into()))?; - *slot = Object::Dict(d); - } - body.extend_from_slice(b"/Annots ["); - for i in 0..count { - let _ = write!(body, "{} 0 R ", i + 2); - } - body.extend_from_slice(b"] "); - } - body.extend_from_slice(b"/F "); - body.extend(pdf_string(file)); - body.extend_from_slice(b" >> >>"); - copy.finish(doc)?; - let mut out = b"%FDF-1.2\n%\xE2\xE3\xCF\xD3\n".to_vec(); - let mut offsets = Vec::with_capacity(copy.objects.len() + 1); - offsets.push(out.len()); - out.extend_from_slice(b"1 0 obj\n"); - out.extend(body); - out.extend_from_slice(b"\nendobj\n"); - for (i, object) in copy.objects.into_iter().enumerate() { - offsets.push(out.len()); - let _ = writeln!(out, "{} 0 obj", i + 2); - pdfcraft_cos::serialize(&object, &mut out); - out.extend_from_slice(b"\nendobj\n"); - } - let xref = out.len(); - let _ = write!(out, "xref\n0 {}\n0000000000 65535 f \n", offsets.len() + 1); - for o in offsets { - let _ = writeln!(out, "{o:010} 00000 n "); - } - let _ = write!(out, "trailer\n<< /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n"); - Ok(out) -} -/// XML 1.0 fifth-edition NameStartChar, excluding `:` for an unprefixed NCName. -/// https://www.w3.org/TR/2008/REC-xml-20081126/#NT-NameStartChar -fn xml_name_start(c: char) -> bool { - matches!( - c, - '_' | 'A'..='Z' | 'a'..='z' - | '\u{C0}'..='\u{D6}' | '\u{D8}'..='\u{F6}' | '\u{F8}'..='\u{2FF}' - | '\u{370}'..='\u{37D}' | '\u{37F}'..='\u{1FFF}' - | '\u{200C}'..='\u{200D}' | '\u{2070}'..='\u{218F}' - | '\u{2C00}'..='\u{2FEF}' | '\u{3001}'..='\u{D7FF}' - | '\u{F900}'..='\u{FDCF}' | '\u{FDF0}'..='\u{FFFD}' - | '\u{10000}'..='\u{EFFFF}' - ) -} - -fn xml_name_char(c: char) -> bool { - xml_name_start(c) || matches!(c, '-' | '.' | '0'..='9' | '\u{B7}' | '\u{300}'..='\u{36F}' | '\u{203F}'..='\u{2040}') -} - -/// Keep the existing letters/digits/`_`/`-`/`.` normalization, constrained to valid NCNames. -fn xml_name(name: &str) -> String { - let mut s: String = - name.chars().map(|c| if (c.is_alphanumeric() && xml_name_char(c)) || matches!(c, '_' | '-' | '.') { c } else { '_' }).collect(); - if !s.chars().next().is_some_and(|c| (c.is_alphabetic() && xml_name_start(c)) || c == '_') { - s.insert(0, '_'); - } - s -} - /// Form data as XML, CSV or tab-delimited text (Acrobat's Export data formats). pub fn export_data(doc: &Document, format: Format) -> String { let fields: Vec<(String, String)> = pdfcraft_forms::fields(doc) @@ -847,7 +95,7 @@ pub fn export_data(doc: &Document, format: Format) -> String { .map(|f| { let v = match f.kind { FieldKind::CheckBox | FieldKind::Radio if f.value.is_empty() => "Off".to_string(), - _ => f.value.join(", "), + _ => f.export_values().join(", "), }; (f.name, v) }) @@ -864,7 +112,11 @@ pub fn export_data(doc: &Document, format: Format) -> String { out } Format::Csv => { - let q = |s: &str| if s.contains([',', '"', '\n', '\r', '\t']) { format!("\"{}\"", s.replace('"', "\"\"")) } else { s.to_string() }; + // A formula-like cell gets a leading `'`, as in `merge_csv`; import takes it off again. + let q = |s: &str| { + let s = if csv_guarded(s) { format!("'{s}") } else { s.to_string() }; + if s.contains([',', '"', '\n', '\r', '\t']) { format!("\"{}\"", s.replace('"', "\"\"")) } else { s } + }; format!( "{}\n{}\n", fields.iter().map(|f| q(&f.0)).collect::>().join(","), @@ -882,483 +134,6 @@ pub fn export_data(doc: &Document, format: Format) -> String { } } -// ── import ────────────────────────────────────────────────────────────────────────────────── - -/// Set the values of fields named in `values` (full names), counting what took. -fn apply_values(doc: &mut Document, values: &[(String, Vec)], report: &mut Report) { - let all = pdfcraft_forms::fields(doc); - for (name, vals) in values { - let Some(f) = all.iter().find(|f| &f.name == name) else { continue }; - let first = vals.first().cloned().unwrap_or_default(); - let v = match f.kind { - FieldKind::Text => FieldValue::Text(first), - FieldKind::CheckBox => FieldValue::Check(!first.is_empty() && first != "Off"), - FieldKind::Radio => FieldValue::Radio((!first.is_empty() && first != "Off").then_some(first)), - // XML, CSV and text hold a multi-select list's values in one cell, joined by ", " - // (as export_data writes them); split it unless the whole cell is one option. - FieldKind::List if f.has(pdfcraft_forms::flags::MULTI_SELECT) && vals.len() == 1 && !f.options.iter().any(|(e, _)| *e == first) => { - FieldValue::Choice(first.split(", ").filter(|v| !v.is_empty()).map(str::to_string).collect()) - } - FieldKind::Combo | FieldKind::List => FieldValue::Choice(vals.iter().filter(|v| !v.is_empty()).cloned().collect()), - FieldKind::PushButton | FieldKind::Signature => continue, - }; - // Read-only fields still take imported data, as in Acrobat. - let ro = f.read_only(); - if ro { - let _ = doc.update_dict(f.obj, |d| d.set(b"Ff".to_vec(), Object::Int((f.flags & !pdfcraft_forms::flags::READ_ONLY) as i64))); - } - match pdfcraft_forms::set_value(doc, name, &v) { - Ok(()) => report.fields += 1, - Err(_) => report.rejected.push(name.clone()), - } - if ro { - let _ = doc.update_dict(f.obj, |d| d.set(b"Ff".to_vec(), Object::Int(f.flags as i64))); - } - } -} - -/// A new annotation dictionary from an XFDF element. -fn annot_from_xml(node: roxmltree::Node, subtype: &str, page_ref: ObjRef) -> Option { - let mut d = Dict::new(); - d.set(b"Type".to_vec(), Object::name("Annot")); - d.set(b"Subtype".to_vec(), Object::name(subtype)); - d.set(b"P".to_vec(), Object::Ref(page_ref)); - let rect = csv(node.attribute("rect")?); - if rect.len() != 4 { - return None; - } - d.set(b"Rect".to_vec(), arr(&rect)); - for (attr, key) in [ - ("name", &b"NM"[..]), - ("title", b"T"), - ("subject", b"Subj"), - ("date", b"M"), - ("creationdate", b"CreationDate"), - ("state", b"State"), - ("statemodel", b"StateModel"), - ] { - if let Some(v) = node.attribute(attr) { - d.set(key.to_vec(), PdfString::text(v)); - } - } - if let Some(v) = node.attribute("replyType") { - d.set(b"RT".to_vec(), Object::name(if v.eq_ignore_ascii_case("group") { "Group" } else { "R" })); - } - if let Some(v) = node.attribute("icon") { - d.set(b"Name".to_vec(), Object::name(v)); - } - let flags: i64 = node - .attribute("flags") - .unwrap_or("print") - .split(',') - .filter_map(|f| FLAGS.iter().find(|(_, s)| s.eq_ignore_ascii_case(f.trim())).map(|x| x.0)) - .fold(0, |bits, flag| bits | flag); - d.set(b"F".to_vec(), Object::Int(flags)); - if let Some(c) = node.attribute("color").and_then(parse_hex) { - d.set(b"C".to_vec(), arr(&c)); - } - if let Some(c) = node.attribute("interior-color").and_then(parse_hex) { - d.set(b"IC".to_vec(), arr(&c)); - } - if let Some(o) = node.attribute("opacity").and_then(|o| o.parse::().ok()) { - d.set(b"CA".to_vec(), Object::Real(o.clamp(0.0, 1.0))); - } - if let Some(w) = node.attribute("width").and_then(|w| w.parse::().ok()) { - let mut bs = Dict::new(); - bs.set(b"W".to_vec(), Object::Real(w.max(0.0))); - bs.set(b"S".to_vec(), Object::name("S")); - d.set(b"BS".to_vec(), Object::Dict(bs)); - } - if let Some(q) = node.attribute("coords") { - d.set(b"QuadPoints".to_vec(), arr(&csv(q))); - } - if let (Some(s), Some(e)) = (node.attribute("start"), node.attribute("end")) { - let (s, e) = (csv(s), csv(e)); - if s.len() == 2 && e.len() == 2 { - d.set(b"L".to_vec(), arr(&[s[0], s[1], e[0], e[1]])); - } - } - if node.attribute("head").is_some() || node.attribute("tail").is_some() { - d.set( - b"LE".to_vec(), - Object::Array(vec![Object::name(node.attribute("head").unwrap_or("None")), Object::name(node.attribute("tail").unwrap_or("None"))]), - ); - } - if let Some(cl) = node.attribute("callout").map(csv).filter(|c| c.len() == 4 || c.len() == 6) { - d.set(b"CL".to_vec(), arr(&cl)); - if let Some(rd) = node.attribute("fringe").map(csv).filter(|r| r.len() == 4) { - d.set(b"RD".to_vec(), arr(&rd)); - } - d.set(b"IT".to_vec(), Object::name(node.attribute("intent").unwrap_or("FreeTextCallout"))); - // A callout's /LE is one name, not the head/tail pair of a line. - match node.attribute("head") { - Some(h) => d.set(b"LE".to_vec(), Object::name(h)), - None => { - d.remove(b"LE"); - } - } - } - for child in node.children().filter(|c| c.is_element()) { - match child.tag_name().name() { - "contents" => { - d.set(b"Contents".to_vec(), PdfString::text(&child.text().unwrap_or("").replace("\r\n", "\r").replace('\n', "\r"))); - } - "defaultappearance" => { - d.set(b"DA".to_vec(), PdfString::literal(child.text().unwrap_or("").as_bytes().to_vec())); - } - "inklist" => { - let list: Vec = child - .children() - .filter(|g| g.has_tag_name("gesture")) - .map(|g| arr(&g.text().unwrap_or("").split(';').flat_map(|p| csv(p).into_iter()).collect::>())) - .collect(); - d.set(b"InkList".to_vec(), Object::Array(list)); - } - "vertices" => { - // Whole x,y pairs only; a stray trailing number is dropped. - let mut v = csv(child.text().unwrap_or("")); - v.truncate(v.len() - v.len() % 2); - if !v.is_empty() { - d.set(b"Vertices".to_vec(), arr(&v)); - } - } - _ => {} - } - } - Some(d) -} - -/// Put an annotation dictionary on a page, replacing one with the same `/NM`. Returns its -/// reference. -/// `/F` bits an imported file may not set on a redaction mark: Invisible, Hidden, NoView — so a -/// mark it brings in is always drawn for the user to review before content is removed. -const REDACT_UNSEEN: i64 = 1 | 2 | 32; -/// `/F` Locked (ISO 32000-2 Table 167): the user protected this annotation from change. -const LOCKED: i64 = 128; - -/// An existing annotation an import must never replace, move or unlock: one the user locked, a -/// redaction mark (replacing it would drop the region the user marked to remove), or a form -/// widget or field (replacing it would set the field's value and policy around the form setter). -fn import_protected(doc: &Document, x: &Dict) -> bool { - let flags = x.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0); - flags & LOCKED != 0 || matches!(x.name(b"Subtype"), Some(b"Redact" | b"Widget")) || x.contains(b"FT") -} - -fn place(doc: &mut Document, page_ref: ObjRef, mut d: Dict) -> Result { - // A redaction mark the import carries is always made visible: clear any Invisible/Hidden/NoView - // flag so it cannot remove content the user never sees. - if d.name(b"Subtype") == Some(b"Redact".as_slice()) { - let f = d.int(b"F").unwrap_or(0); - if f & REDACT_UNSEEN != 0 { - d.set(b"F".to_vec(), Object::Int(f & !REDACT_UNSEEN)); - } - // Its review outline is drawn opaque, and from the area it removes: applying a mark - // without usable /QuadPoints removes its /Rect, which the outline is then drawn from. - d.remove(b"CA"); - d.remove(b"ca"); - let quads_drawable = d.get(b"QuadPoints").and_then(Object::as_array).is_some_and(|q| { - let v: Vec = q.iter().filter_map(Object::as_f64).filter(|v| v.is_finite()).collect(); - let (quads, rest) = v.as_chunks::<8>(); - v.len() == q.len() && rest.is_empty() && !quads.is_empty() && quads.iter().all(quad_has_area) - }); - if !quads_drawable { - // Quads the outline can't draw, or any quad that encloses no area, are replaced by the - // /Rect's, or dropped when there is no usable /Rect either; such a mark then removes - // nothing. - match rect_strict(doc, d.get(b"Rect")) { - Some([x0, y0, x1, y1]) => { - let (l, r, b, t) = (x0.min(x1), x0.max(x1), y0.min(y1), y0.max(y1)); - d.set(b"QuadPoints".to_vec(), arr(&[l, t, r, t, l, b, r, b])); - } - _ => { - d.remove(b"QuadPoints"); - } - } - } - } - let nm = d.get(b"NM").and_then(|n| n.as_string().map(|s| s.to_text())); - let annots: Vec = doc - .get(page_ref) - .as_dict() - .and_then(|p| p.get(b"Annots").cloned()) - .map(|a| doc.resolve(&a)) - .and_then(|a| a.as_array().cloned()) - .unwrap_or_default(); - let mut kept = Vec::new(); - let mut removed: Vec = Vec::new(); - // Never let a same-name import replace a protected annotation (`import_protected`): keep it, - // and add the imported dict beside it under no name of its own. - let mut clash = false; - for a in annots { - let existing = a.as_ref().and_then(|r| doc.get(r).as_dict().cloned()); - let same = nm.is_some() && existing.as_ref().and_then(|x| x.get(b"NM").and_then(|n| doc.resolve(n).as_string().map(|s| s.to_text()))) == nm; - if same && existing.as_ref().is_some_and(|x| import_protected(doc, x)) { - clash = true; - kept.push(a); - } else if same { - removed.extend(a.as_ref()); - } else { - kept.push(a); - } - } - if clash { - d.remove(b"NM"); - } - // The replaced note's pop-up goes with it; otherwise it stays on the page pointing at a note that is no longer there. - kept.retain(|a| { - let Some(x) = a.as_ref().map(|r| doc.get(r)).and_then(|o| o.as_dict().cloned()) else { return true }; - x.name(b"Subtype") != Some(b"Popup".as_slice()) || x.reference(b"Parent").is_none_or(|p| !removed.contains(&p)) - }); - // Keep the replaced comment's identity: replies and review statuses not included in a - // partial import still point at it through /IRT. Allocating a new object would detach - // those comments and leave their parent outside the page's annotation list. - let r = match removed.first().copied() { - Some(r) => { - doc.set(r, Object::Dict(d)); - r - } - None => doc.add(Object::Dict(d)), - }; - // Our own appearance where we can draw one; others keep none (viewers draw a default). - let _ = pdfcraft_annot::set_appearance(doc, r); - kept.push(Object::Ref(r)); - doc.update_dict(page_ref, |p| p.set(b"Annots".to_vec(), Object::Array(kept)))?; - Ok(r) -} - -/// A pop-up window for the comment `parent`, on its page. -fn add_popup(doc: &mut Document, page: ObjRef, parent: ObjRef, rect: &[f64], open: bool) -> Result<(), DataError> { - let mut p = Dict::new(); - p.set(b"Type".to_vec(), Object::name("Annot")); - p.set(b"Subtype".to_vec(), Object::name("Popup")); - p.set(b"Rect".to_vec(), arr(rect)); - p.set(b"Parent".to_vec(), Object::Ref(parent)); - p.set(b"Open".to_vec(), Object::Bool(open)); - let pr = doc.add(Object::Dict(p)); - doc.update_dict(parent, |d| d.set(b"Popup".to_vec(), Object::Ref(pr)))?; - doc.update_dict(page, |pd| { - if let Some(Object::Array(a)) = pd.get_mut(b"Annots") { - a.push(Object::Ref(pr)); - } - })?; - Ok(()) -} - -fn import_xfdf(doc: &mut Document, text: &str) -> Result { - check_form_xml_depth(text)?; - let xml = parse_form_xml(text).map_err(|e| DataError::Malformed(e.to_string()))?; - let root = xml.root_element(); - if root.tag_name().name() != "xfdf" { - return Err(DataError::UnknownFormat); - } - let mut report = Report::default(); - let pages: Vec = pdfcraft_model::pages(doc).iter().map(|p| p.obj).collect(); - // Comments: parents first, then replies (whose /IRT needs the parent's reference). - let mut by_name: HashMap = HashMap::new(); - let mut later: HashMap = HashMap::new(); - if let Some(annots) = root.children().find(|c| c.has_tag_name("annots")) { - for node in annots.children().filter(|c| c.is_element()) { - let tag = node.tag_name().name().to_ascii_lowercase(); - let Some((subtype, _)) = SUBTYPES.iter().find(|(_, t)| *t == tag) else { continue }; - let Some(page) = node.attribute("page").and_then(|p| p.parse::().ok()).and_then(|p| pages.get(p).copied()) else { continue }; - let Some(d) = annot_from_xml(node, subtype, page) else { continue }; - let r = place(doc, page, d)?; - // A later same-name record replaces this comment, including its reply relation. - later.remove(&r); - if let Some(nm) = node.attribute("name") { - by_name.insert(nm.to_string(), r); - } - if let Some(irt) = node.attribute("inreplyto") { - later.insert(r, irt.to_string()); - } - // A pop-up window. - if let Some(pop) = node.children().find(|c| c.has_tag_name("popup")) - && let Some(rect) = pop.attribute("rect").map(csv).filter(|r| r.len() == 4) - { - add_popup(doc, page, r, &rect, pop.attribute("open") == Some("yes"))?; - } - report.comments += 1; - } - } - for (r, irt) in later { - if let Some(parent) = by_name.get(&irt).copied().or_else(|| find_by_name(doc, &irt)) { - doc.update_dict(r, |d| d.set(b"IRT".to_vec(), Object::Ref(parent)))?; - } - } - // Fields. - if let Some(fields) = root.children().find(|c| c.has_tag_name("fields")) { - let mut values = Vec::new(); - walk_xfdf(fields, "", &mut values, 0)?; - apply_values(doc, &values, &mut report); - } - Ok(report) -} - -fn find_by_name(doc: &Document, nm: &str) -> Option { - for p in pdfcraft_model::pages(doc) { - for a in p.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default() { - if let Some(r) = a.as_ref() - && doc.get(r).as_dict().and_then(|d| text_of(doc, d, b"NM")).as_deref() == Some(nm) - { - return Some(r); - } - } - } - None -} - -/// What one FDF import may copy out of the file. A field tree or comment that repeats a reference -/// level after level would otherwise expand a small file into millions of values. -const MAX_FDF_IMPORT_VALUES: usize = 1_000_000; - -struct ImportBudget { - values: usize, - bytes: usize, -} - -impl ImportBudget { - fn new() -> Self { - Self { values: MAX_FDF_IMPORT_VALUES, bytes: MAX_FDF_BYTES } - } - - fn spend(&mut self, values: usize, bytes: usize) -> Result<(), DataError> { - match (self.values.checked_sub(values), self.bytes.checked_sub(bytes)) { - (Some(v), Some(b)) => { - (self.values, self.bytes) = (v, b); - Ok(()) - } - _ => Err(DataError::Malformed(format!( - "the file expands to more than {MAX_FDF_IMPORT_VALUES} values or {} MiB of text", - MAX_FDF_BYTES >> 20 - ))), - } - } -} - -/// Copy an object out of another document, resolving references. A reference back to an object -/// being copied becomes null. -fn deep(src: &Document, o: &Object, depth: usize, path: &mut Vec, budget: &mut ImportBudget) -> Result { - budget.spend(1, 0)?; - if depth > 16 { - return Ok(Object::Null); - } - Ok(match o { - Object::Ref(r) => { - if path.contains(r) { - return Ok(Object::Null); - } - path.push(*r); - let copy = deep(src, &src.get(*r), depth + 1, path, budget); - path.pop(); - copy? - } - Object::Array(a) => Object::Array(a.iter().map(|x| deep(src, x, depth + 1, path, budget)).collect::>()?), - Object::Dict(d) => { - Object::Dict(d.iter().map(|(k, v)| Ok((k.clone(), deep(src, v, depth + 1, path, budget)?))).collect::>()?) - } - Object::Stream(_) => Object::Null, - Object::String(s) => { - budget.spend(0, s.bytes.len())?; - o.clone() - } - Object::Name(n) => { - budget.spend(0, n.len())?; - o.clone() - } - other => other.clone(), - }) -} - -fn walk_xfdf(node: roxmltree::Node, prefix: &str, out: &mut Vec<(String, Vec)>, depth: usize) -> Result<(), DataError> { - if depth > MAX_DEPTH { - return Err(DataError::Malformed(format!("XFDF fields nest more than {MAX_DEPTH} deep"))); - } - for f in node.children().filter(|c| c.has_tag_name("field")) { - let Some(name) = f.attribute("name") else { continue }; - let full = if prefix.is_empty() { name.to_string() } else { format!("{prefix}.{name}") }; - let vals: Vec = f - .children() - .filter(|c| c.has_tag_name("value")) - // Comments and processing instructions can split a value into several text nodes. - .map(|v| v.children().filter(|n| n.is_text()).filter_map(|n| n.text()).collect()) - .collect(); - // A leaf with no `` is an empty field (the export writes one that way), so it - // clears the field like FDF's `/V ()`; a field with kids only names their parent. A - // leaf holding only `` is not read yet, so it leaves the field alone. - let names_kids_or_rich = f.children().any(|c| c.has_tag_name("field") || c.has_tag_name("value-richtext")); - if !vals.is_empty() || !names_kids_or_rich { - out.push((full.clone(), vals)); - } - walk_xfdf(f, &full, out, depth + 1)?; - } - Ok(()) -} - -/// Read an FDF field tree. A field listed again beneath itself is skipped. -fn walk_fdf( - fdf: &Document, - list: &[Object], - prefix: &str, - out: &mut Vec<(String, Vec)>, - depth: usize, - path: &mut Vec, - budget: &mut ImportBudget, -) -> Result<(), DataError> { - if depth > 32 { - return Ok(()); - } - for f in list { - budget.spend(1, 0)?; - let held; - let field = match f { - Object::Ref(r) if path.contains(r) => continue, - Object::Ref(r) => { - held = fdf.get(*r); - &*held - } - direct => direct, - }; - let Some(d) = field.as_dict() else { continue }; - let Some(t) = d.get(b"T").and_then(|t| fdf.resolve(t).as_string().map(|s| s.to_text())) else { continue }; - let full = if prefix.is_empty() { t } else { format!("{prefix}.{t}") }; - budget.spend(0, full.len())?; - if let Some(v) = d.get(b"V").map(|v| fdf.resolve(v)) { - let vals: Vec = match &*v { - Object::String(s) => vec![s.to_text()], - Object::Name(n) => vec![pdfcraft_forms::name_text(n)], - Object::Array(a) => { - budget.spend(a.len(), 0)?; - a.iter().filter_map(|x| fdf.resolve(x).as_string().map(|s| s.to_text())).collect() - } - _ => Vec::new(), - }; - budget.spend(vals.len(), vals.iter().map(String::len).fold(0, usize::saturating_add))?; - out.push((full.clone(), vals)); - } - if let Some(kids) = d.get(b"Kids").map(|k| fdf.resolve(k)) - && let Some(kids) = kids.as_array() - { - let entered = f.as_ref().map(|r| path.push(r)).is_some(); - let walked = walk_fdf(fdf, kids, &full, out, depth + 1, path, budget); - if entered { - path.pop(); - } - walked?; - } - } - Ok(()) -} - -fn fdf_body(fdf: &Document) -> Result { - let root = fdf.root().ok_or_else(|| DataError::Malformed("no /Root".into()))?; - fdf.get(root) - .as_dict() - .and_then(|d| d.get(b"FDF").cloned()) - .map(|f| fdf.resolve(&f)) - .and_then(|f| f.as_dict().cloned()) - .ok_or(DataError::UnknownFormat) -} - /// The field values in a form data file (FDF or XFDF) or a filled-in PDF form, in file order. pub fn data_values(bytes: &[u8]) -> Result)>, DataError> { if bytes.starts_with(b"%FDF") { @@ -1377,8 +152,11 @@ pub fn data_values(bytes: &[u8]) -> Result)>, DataError .into_iter() .filter(|f| !matches!(f.kind, FieldKind::PushButton | FieldKind::Signature)) .map(|f| { - let v = - if matches!(f.kind, FieldKind::CheckBox | FieldKind::Radio) && f.value.is_empty() { vec!["Off".to_string()] } else { f.value }; + let v = if matches!(f.kind, FieldKind::CheckBox | FieldKind::Radio) && f.value.is_empty() { + vec!["Off".to_string()] + } else { + f.export_values() + }; (f.name, v) }) .collect()); @@ -1413,15 +191,7 @@ pub fn merge_csv(files: &[Vec<(String, Vec)>]) -> String { } } } - let literal = |s: &str| { - let t = s.trim_start(); - let formula = match t.chars().next() { - Some('=' | '@') => true, - Some('+' | '-') => !t.parse::().is_ok_and(f64::is_finite), - _ => false, - }; - if formula { format!("'{s}") } else { s.to_string() } - }; + let literal = |s: &str| if runs_as_formula(s) { format!("'{s}") } else { s.to_string() }; let q = |s: &str| { let s = literal(s); if s.contains([',', '"', '\n', '\r', '\t']) { format!("\"{}\"", s.replace('"', "\"\"")) } else { s } @@ -1436,64 +206,30 @@ pub fn merge_csv(files: &[Vec<(String, Vec)>]) -> String { out } -fn import_fdf(doc: &mut Document, bytes: &[u8]) -> Result { - let fdf = Document::open(std::sync::Arc::new(bytes.to_vec())).map_err(|e| DataError::Malformed(e.to_string()))?; - let body = fdf_body(&fdf)?; - let mut report = Report::default(); - let mut budget = ImportBudget::new(); - // Fields (with /Kids for hierarchical names). - let mut values = Vec::new(); - if let Some(list) = body.get(b"Fields").map(|f| fdf.resolve(f)).and_then(|f| f.as_array().cloned()) { - walk_fdf(&fdf, &list, "", &mut values, 0, &mut Vec::new(), &mut budget)?; - } - // Comments: parents first, then replies (whose /IRT needs the parent's new reference). - let pages: Vec = pdfcraft_model::pages(doc).iter().map(|p| p.obj).collect(); - let mut placed: HashMap = HashMap::new(); - let mut later: HashMap, Option)> = HashMap::new(); - for a in body.get(b"Annots").map(|a| fdf.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default() { - let Some(mut src) = fdf.resolve(&a).as_dict().cloned() else { continue }; - // The parent is another comment: link it below rather than copying it in. - let irt = src.get(b"IRT").cloned(); - src.remove(b"IRT"); - // The pop-up is an annotation of its own (/Popup shall be an indirect reference, ISO - // 32000-2 Table 172): copied in, it would become a direct dictionary. A new one is - // placed on the page below, as the XFDF import does. - let popup = src.get(b"Popup").map(|p| fdf.resolve(p)).and_then(|p| p.as_dict().cloned()); - src.remove(b"Popup"); - let mut path: Vec = a.as_ref().into_iter().collect(); - let Object::Dict(mut d) = deep(&fdf, &Object::Dict(src), 0, &mut path, &mut budget)? else { continue }; - let Some(page) = d.get(b"Page").and_then(Object::as_int).and_then(|p| usize::try_from(p).ok()).and_then(|p| pages.get(p).copied()) else { - continue; - }; - if !d.name(b"Subtype").is_some_and(|s| SUBTYPES.iter().any(|(x, _)| x.as_bytes() == s)) { - continue; - } - d.remove(b"Page"); - d.set(b"P".to_vec(), Object::Ref(page)); - let r = place(doc, page, d)?; - later.remove(&r); - if let Some(rect) = popup.as_ref().and_then(|p| rect_strict(&fdf, p.get(b"Rect"))) { - let open = popup.as_ref().and_then(|p| p.get(b"Open")).is_some_and(|o| matches!(&*fdf.resolve(o), Object::Bool(true))); - add_popup(doc, page, r, &rect, open)?; - } - if let Some(fr) = a.as_ref() { - placed.insert(fr, r); - } - if let Some(irt) = irt { - let parent_name = fdf.resolve(&irt).as_dict().and_then(|p| text_of(&fdf, p, b"NM")); - later.insert(r, (irt.as_ref(), parent_name)); - } - report.comments += 1; +/// Whether a spreadsheet program runs CSV cell `s` as a formula: after any leading whitespace it +/// starts with `=` or `@`, or with `+` or `-` and is not a plain number. +fn runs_as_formula(s: &str) -> bool { + let t = s.trim_start(); + match t.chars().next() { + Some('=' | '@') => true, + Some('+' | '-') => !t.parse::().is_ok_and(f64::is_finite), + _ => false, } - for (r, (parent_obj, parent_name)) in later { - // The parent imported from this file, else one already in the document with its name. - let parent = parent_obj.and_then(|p| placed.get(&p).copied()).or_else(|| parent_name.and_then(|nm| find_by_name(doc, &nm))); - if let Some(parent) = parent.filter(|p| *p != r) { - doc.update_dict(r, |d| d.set(b"IRT".to_vec(), Object::Ref(parent)))?; - } +} + +/// Whether Export Data writes CSV cell `s` with a leading `'`: it runs as a formula, or it already +/// starts with `'` in front of one. That second case gets one more `'`, so [`csv_text`] can take +/// exactly one off on import and every value comes back unchanged. +fn csv_guarded(s: &str) -> bool { + runs_as_formula(s.trim_start_matches('\'')) +} + +/// A CSV cell's text without the `'` that Export Data added (see [`csv_guarded`]). +fn csv_text(cell: String) -> String { + match cell.strip_prefix('\'') { + Some(rest) if csv_guarded(rest) => rest.to_string(), + _ => cell, } - apply_values(doc, &values, &mut report); - Ok(report) } // Inspect the first logical record: tabs within quoted CSV cells are data. A quote @@ -1576,7 +312,11 @@ fn import_table(doc: &mut Document, text: &str) -> Result { let (Some(head), Some(row)) = (records.next(), records.next()) else { return Err(DataError::UnknownFormat) }; // Text exported from a one-field form has no tab to show for it (#1321). CSV quotes a // value with a comma, so one name over several unquoted cells is that text, not CSV. - if head.len() == 1 && row.len() > 1 { tab_delimited(t)? } else { head.into_iter().zip(row).map(|(k, v)| (k, vec![v])).collect() } + if head.len() == 1 && row.len() > 1 { + tab_delimited(t)? + } else { + head.into_iter().zip(row).map(|(k, v)| (csv_text(k), vec![csv_text(v)])).collect() + } }; apply_values(doc, &values, &mut report); Ok(report) diff --git a/crates/xfdf/src/tests.rs b/crates/xfdf/src/tests.rs index 2bb54f9232..0da17c1e67 100644 --- a/crates/xfdf/src/tests.rs +++ b/crates/xfdf/src/tests.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use pdfcraft_annot::{Markup, Meta, NewAnnotation, Shape, Style, add_annotation, add_reply, add_text_replacement, summaries}; +use pdfcraft_annot::{Markup, Meta, NewAnnotation, Shape, Style, TextReplacement, add_annotation, add_reply, add_text_replacement, summaries}; use pdfcraft_cos::{SaveOptions, write_full, write_incremental}; use pdfcraft_forms::{NewField, add_field, fields, set_value}; @@ -151,7 +151,20 @@ fn a_replace_text_pair_survives_an_xfdf_round_trip() { let quads = [[100.0, 720.0, 200.0, 720.0, 100.0, 706.0, 200.0, 706.0]]; let style = Style { color: [0.89, 0.13, 0.13], opacity: 1.0, width: 1.0, fill: None }; let meta = |id: &str| Meta { date: Some("D:20261001120000Z".into()), id: id.into() }; - add_text_replacement(&mut src, 0, &quads, "new words", "Ada", &style, &style, &meta("strike"), &meta("caret")).unwrap(); + add_text_replacement( + &mut src, + TextReplacement { + page: 0, + quads: &quads, + replacement: "new words", + author: "Ada", + strike: &style, + caret: &style, + strike_meta: &meta("strike"), + caret_meta: &meta("caret"), + }, + ) + .unwrap(); assert_eq!(summaries(&src).len(), 2, "a strikeout and a caret"); let xfdf = export_xfdf(&src, true, false, "a.pdf"); @@ -610,6 +623,45 @@ fn csv_exports_preserve_carriage_returns_in_names_and_values() { } } +/// Export Data as CSV writes a name or value that a spreadsheet would run as a formula with a +/// leading `'`, by the same rule as Merge Data Files, and Import Data gives it back unchanged. +#[test] +fn exported_csv_cells_never_start_a_formula_and_import_back_unchanged() { + for (name, value, expected) in [ + ("Name", "=1+1", "Name\n'=1+1\n"), + ("Name", "@SUM(A1:A2)", "Name\n'@SUM(A1:A2)\n"), + ("Name", "+1 555 0100", "Name\n'+1 555 0100\n"), + ("Name", "-A1", "Name\n'-A1\n"), + ("Name", " =1+1", "Name\n' =1+1\n"), + ("Name", "\t=1+1", "Name\n\"'\t=1+1\"\n"), + ("Name", "\r=1+1", "Name\n\"'\r=1+1\"\n"), + ("Name", "=1,2", "Name\n\"'=1,2\"\n"), + ("Name", "'=1+1", "Name\n''=1+1\n"), + ("Name", "-12.5", "Name\n-12.5\n"), + ("Name", "+1", "Name\n+1\n"), + ("Name", "'-5", "Name\n'-5\n"), + ("Name", "'plain", "Name\n'plain\n"), + ("Name", "a=b", "Name\na=b\n"), + ("=Name", "Ada", "'=Name\nAda\n"), + ] { + let mut src = one_field(); + let field = fields(&src)[0].obj; + src.update_dict(field, |d| d.set(b"T".to_vec(), PdfString::text(name))).unwrap(); + set_value(&mut src, name, &FieldValue::Text(value.into())).unwrap(); + let source = Arc::new(write_full(&src, &SaveOptions::default()).unwrap()); + let src = Document::open(source.clone()).unwrap(); + let csv = export_data(&src, Format::Csv); + assert_eq!(csv, expected, "{value:?}"); + + let mut dst = Document::open(source).unwrap(); + set_value(&mut dst, name, &FieldValue::Text("replaced".into())).unwrap(); + let report = import(&mut dst, csv.as_bytes()).unwrap(); + assert_eq!(report.fields, 1, "{value:?}"); + assert!(report.rejected.is_empty(), "{report:?}"); + assert_eq!(values(&dst), [(name.to_string(), vec![value.to_string()])]); + } +} + /// #1321: the text export of a one-field form has no tab to tell it from CSV, so a comma in the /// value used to split it and only the text before the comma was imported. #[test] @@ -1191,6 +1243,39 @@ fn duplicate_xfdf_annotation_flags_do_not_turn_on_unrelated_bits() { } } +#[test] +fn edited_button_export_values_round_trip_in_all_data_formats() { + use pdfcraft_forms::{FieldProps, set_props}; + let mut doc = blank(); + add_field(&mut doc, 0, [80.0, 650.0, 94.0, 664.0], &NewField::Radio { group: Some("Size".into()), export: "S".into() }, None).unwrap(); + add_field(&mut doc, 0, [100.0, 650.0, 114.0, 664.0], &NewField::Radio { group: Some("Size".into()), export: "L".into() }, None).unwrap(); + set_props(&mut doc, "Agree", &FieldProps { export_value: Some((0, "Accepted".into())), ..Default::default() }).unwrap(); + set_props(&mut doc, "Size", &FieldProps { export_value: Some((0, "Small".into())), ..Default::default() }).unwrap(); + set_value(&mut doc, "Agree", &FieldValue::Check(true)).unwrap(); + set_value(&mut doc, "Size", &FieldValue::Radio(Some("Small".into()))).unwrap(); + let doc = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + let mut exports = vec![export_xfdf(&doc, false, true, "").into_bytes(), export_fdf(&doc, false, true, "").unwrap()]; + exports.extend([Format::Xml, Format::Csv, Format::Txt].map(|f| export_data(&doc, f).into_bytes())); + for (index, bytes) in exports.into_iter().enumerate() { + if index < 2 { + let values = data_values(&bytes).unwrap(); + assert_eq!(values.iter().find(|(n, _)| n == "Agree").unwrap().1, vec!["Accepted"]); + assert_eq!(values.iter().find(|(n, _)| n == "Size").unwrap().1, vec!["Small"]); + } else { + // data_values reads XFDF/FDF/PDF; import also handles XML/CSV/TXT. + let text = std::str::from_utf8(&bytes).unwrap(); + assert!(text.contains("Accepted") && text.contains("Small"), "{text}"); + } + let mut target = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + set_value(&mut target, "Agree", &FieldValue::Check(false)).unwrap(); + set_value(&mut target, "Size", &FieldValue::Radio(Some("L".into()))).unwrap(); + import(&mut target, &bytes).unwrap(); + let fs = fields(&target); + assert_eq!(fs.iter().find(|f| f.name == "Agree").unwrap().export_values(), vec!["Accepted"]); + assert_eq!(fs.iter().find(|f| f.name == "Size").unwrap().export_values(), vec!["Small"]); + } +} + // XML comments and processing instructions are not field text (XML 1.0 §§2.5–2.6). fn split_text_values() -> [(&'static str, &'static str); 5] { [ diff --git a/crates/xfdf/src/xfdf.rs b/crates/xfdf/src/xfdf.rs new file mode 100644 index 0000000000..06ad9f34ec --- /dev/null +++ b/crates/xfdf/src/xfdf.rs @@ -0,0 +1,536 @@ +use super::common::{ + FLAGS, MAX_DEPTH, SUBTYPES, arr, check_form_xml_depth, csv, esc, hex, n, nums_of, parse_form_xml, parse_hex, quad_has_area, rect_strict, text_of, + walk_xfdf, +}; +use super::*; + +// ── export ────────────────────────────────────────────────────────────────────────────────── + +/// Every comment as an XFDF element (one line per comment, replies included). +fn xfdf_annots(doc: &Document, out: &mut String) { + let pages = pdfcraft_model::pages(doc); + // NM of every annotation, for inreplyto. + let mut names: HashMap = HashMap::new(); + for p in &pages { + for a in p.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default() { + if let Some(r) = a.as_ref() + && let Some(nm) = doc.get(r).as_dict().and_then(|d| text_of(doc, d, b"NM")) + { + names.insert(r, nm); + } + } + } + for (pi, p) in pages.iter().enumerate() { + for a in p.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default() { + let Some(d) = doc.resolve(&a).as_dict().cloned() else { continue }; + let Some(sub) = d.name(b"Subtype") else { continue }; + let Some((_, tag)) = SUBTYPES.iter().find(|(s, _)| s.as_bytes() == sub) else { continue }; + let mut attrs = format!(" page=\"{pi}\""); + let rect = nums_of(doc, d.get(b"Rect")); + if rect.len() == 4 { + let _ = write!( + attrs, + " rect=\"{},{},{},{}\"", + n(rect[0].min(rect[2])), + n(rect[1].min(rect[3])), + n(rect[0].max(rect[2])), + n(rect[1].max(rect[3])) + ); + } + for (key, attr) in [ + (&b"NM"[..], "name"), + (b"T", "title"), + (b"Subj", "subject"), + (b"M", "date"), + (b"CreationDate", "creationdate"), + (b"RT", "replyType"), + (b"State", "state"), + (b"StateModel", "statemodel"), + (b"Name", "icon"), + ] { + if let Some(v) = text_of(doc, &d, key) { + let _ = write!(attrs, " {attr}=\"{}\"", esc(&v)); + } + } + let flags = d.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0); + let fl: Vec<&str> = FLAGS.iter().filter(|(b, _)| flags & b != 0).map(|(_, s)| *s).collect(); + let _ = write!(attrs, " flags=\"{}\"", fl.join(",")); + if let Some(c) = hex(&nums_of(doc, d.get(b"C"))) { + let _ = write!(attrs, " color=\"{c}\""); + } + if let Some(c) = hex(&nums_of(doc, d.get(b"IC"))) { + let _ = write!(attrs, " interior-color=\"{c}\""); + } + if let Some(o) = d.get(b"CA").and_then(|o| doc.resolve(o).as_f64()) { + let _ = write!(attrs, " opacity=\"{}\"", n(o)); + } + if let Some(w) = d.get(b"BS").and_then(|b| doc.resolve(b).as_dict().and_then(|b| b.get(b"W").and_then(Object::as_f64))) { + let _ = write!(attrs, " width=\"{}\"", n(w)); + } + let quads = nums_of(doc, d.get(b"QuadPoints")); + if !quads.is_empty() { + let _ = write!(attrs, " coords=\"{}\"", quads.iter().map(|v| n(*v)).collect::>().join(",")); + } + let l = nums_of(doc, d.get(b"L")); + if l.len() == 4 { + let _ = write!(attrs, " start=\"{},{}\" end=\"{},{}\"", n(l[0]), n(l[1]), n(l[2]), n(l[3])); + } + if let Some(le) = d.get(b"LE").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()) + && le.len() == 2 + { + let name = |o: &Object| o.as_name().map(|x| String::from_utf8_lossy(x).into_owned()).unwrap_or_else(|| "None".into()); + let _ = write!(attrs, " head=\"{}\" tail=\"{}\"", esc(&name(&le[0])), esc(&name(&le[1]))); + } + // A callout: its leader path, text-box inset, intent and (single-name) line ending. + let cl = nums_of(doc, d.get(b"CL")); + if cl.len() == 4 || cl.len() == 6 { + let _ = write!(attrs, " callout=\"{}\"", cl.iter().map(|v| n(*v)).collect::>().join(",")); + let rd = nums_of(doc, d.get(b"RD")); + if rd.len() == 4 { + let _ = write!(attrs, " fringe=\"{}\"", rd.iter().map(|v| n(*v)).collect::>().join(",")); + } + if let Some(it) = d.get(b"IT").map(|o| doc.resolve(o)).and_then(|o| o.as_name().map(|x| String::from_utf8_lossy(x).into_owned())) { + let _ = write!(attrs, " intent=\"{}\"", esc(&it)); + } + if let Some(le) = d.get(b"LE").map(|o| doc.resolve(o)).and_then(|o| o.as_name().map(|x| String::from_utf8_lossy(x).into_owned())) { + let _ = write!(attrs, " head=\"{}\"", esc(&le)); + } + } + if let Some(irt) = d.get(b"IRT").and_then(Object::as_ref).and_then(|r| names.get(&r)) { + let _ = write!(attrs, " inreplyto=\"{}\"", esc(irt)); + } + let _ = write!(out, "<{tag}{attrs}>"); + if let Some(c) = text_of(doc, &d, b"Contents") { + let _ = write!(out, "{}", esc(&c)); + } + if let Some(da) = text_of(doc, &d, b"DA") { + let _ = write!(out, "{}", esc(&da)); + } + let ink = d.get(b"InkList").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); + if !ink.is_empty() { + out.push_str(""); + for g in ink { + let v = nums_of(doc, Some(&g)); + let pts: Vec = v.as_chunks::<2>().0.iter().map(|p| format!("{},{}", n(p[0]), n(p[1]))).collect(); + let _ = write!(out, "{}", pts.join(";")); + } + out.push_str(""); + } + // Polygon and polyline points (XFDF ``: `x,y;x,y;…`). + let vertices = nums_of(doc, d.get(b"Vertices")); + let pts: Vec = vertices.as_chunks::<2>().0.iter().map(|p| format!("{},{}", n(p[0]), n(p[1]))).collect(); + if !pts.is_empty() { + let _ = write!(out, "{}", pts.join(";")); + } + if let Some(pd) = d.get(b"Popup").map(|p| doc.resolve(p)).and_then(|p| p.as_dict().cloned()) { + let r = nums_of(doc, pd.get(b"Rect")); + if r.len() == 4 { + let open = matches!(pd.get(b"Open"), Some(Object::Bool(true))); + let _ = write!( + out, + "", + n(r[0]), + n(r[1]), + n(r[2]), + n(r[3]), + if open { "yes" } else { "no" } + ); + } + } + let _ = writeln!(out, ""); + } + } +} + +/// Field values as nested XFDF `` elements. +fn xfdf_fields(doc: &Document, out: &mut String) { + #[derive(Default)] + struct Node { + values: Vec, + kids: Vec<(String, Node)>, + } + let mut root = Node::default(); + for f in pdfcraft_forms::fields(doc) { + if matches!(f.kind, FieldKind::PushButton | FieldKind::Signature) { + continue; + } + let mut node = &mut root; + // One `` level per part of the name, but no more levels than the import reads + // back. A `/T` can hold periods of its own, so a name can split into far more parts than + // the field tree has levels: nesting every part wrote a file the import refuses and, for + // a long enough name, recursed in `write_node` until the stack overflowed. The parts past + // the last level stay together in the deepest element's name, and the import joins them + // back with periods into the same full name. + for part in f.name.splitn(MAX_DEPTH, '.') { + let i = match node.kids.iter().position(|(k, _)| k == part) { + Some(i) => i, + None => { + node.kids.push((part.to_string(), Node::default())); + node.kids.len() - 1 + } + }; + node = &mut node.kids[i].1; + } + node.values = match f.kind { + FieldKind::CheckBox | FieldKind::Radio if f.value.is_empty() => vec!["Off".into()], + _ => f.export_values(), + }; + } + fn write_node(name: &str, node: &Node, out: &mut String) { + let _ = write!(out, "", esc(name)); + for v in &node.values { + let _ = write!(out, "{}", esc(v)); + } + for (k, kid) in &node.kids { + write_node(k, kid, out); + } + out.push_str("\n"); + } + for (k, kid) in &root.kids { + write_node(k, kid, out); + } +} + +/// XFDF with the document's comments and/or field values. +pub fn export_xfdf(doc: &Document, comments: bool, fields: bool, file: &str) -> String { + let mut out = String::from("\n\n"); + if comments { + out.push_str("\n"); + xfdf_annots(doc, &mut out); + out.push_str("\n"); + } + if fields { + out.push_str("\n"); + xfdf_fields(doc, &mut out); + out.push_str("\n"); + } + let _ = writeln!(out, "", esc(file)); + out.push_str("\n"); + out +} + +// ── import ────────────────────────────────────────────────────────────────────────────────── + +/// Set the values of fields named in `values` (full names), counting what took. +pub(crate) fn apply_values(doc: &mut Document, values: &[(String, Vec)], report: &mut Report) { + let all = pdfcraft_forms::fields(doc); + for (name, vals) in values { + let Some(f) = all.iter().find(|f| &f.name == name) else { continue }; + let first = vals.first().cloned().unwrap_or_default(); + let v = match f.kind { + FieldKind::Text => FieldValue::Text(first), + FieldKind::CheckBox => FieldValue::Check(!first.is_empty() && first != "Off"), + FieldKind::Radio => FieldValue::Radio((!first.is_empty() && first != "Off").then_some(first)), + // XML, CSV and text hold a multi-select list's values in one cell, joined by ", " + // (as export_data writes them); split it unless the whole cell is one option. + FieldKind::List if f.has(pdfcraft_forms::flags::MULTI_SELECT) && vals.len() == 1 && !f.options.iter().any(|(e, _)| *e == first) => { + FieldValue::Choice(first.split(", ").filter(|v| !v.is_empty()).map(str::to_string).collect()) + } + FieldKind::Combo | FieldKind::List => FieldValue::Choice(vals.iter().filter(|v| !v.is_empty()).cloned().collect()), + FieldKind::PushButton | FieldKind::Signature => continue, + }; + // Read-only fields still take imported data, as in Acrobat. + let ro = f.read_only(); + if ro { + let _ = doc.update_dict(f.obj, |d| d.set(b"Ff".to_vec(), Object::Int((f.flags & !pdfcraft_forms::flags::READ_ONLY) as i64))); + } + match pdfcraft_forms::set_value(doc, name, &v) { + Ok(()) => report.fields += 1, + Err(_) => report.rejected.push(name.clone()), + } + if ro { + let _ = doc.update_dict(f.obj, |d| d.set(b"Ff".to_vec(), Object::Int(f.flags as i64))); + } + } +} + +/// A new annotation dictionary from an XFDF element. +fn annot_from_xml(node: roxmltree::Node, subtype: &str, page_ref: ObjRef) -> Option { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("Annot")); + d.set(b"Subtype".to_vec(), Object::name(subtype)); + d.set(b"P".to_vec(), Object::Ref(page_ref)); + let rect = csv(node.attribute("rect")?); + if rect.len() != 4 { + return None; + } + d.set(b"Rect".to_vec(), arr(&rect)); + for (attr, key) in [ + ("name", &b"NM"[..]), + ("title", b"T"), + ("subject", b"Subj"), + ("date", b"M"), + ("creationdate", b"CreationDate"), + ("state", b"State"), + ("statemodel", b"StateModel"), + ] { + if let Some(v) = node.attribute(attr) { + d.set(key.to_vec(), PdfString::text(v)); + } + } + if let Some(v) = node.attribute("replyType") { + d.set(b"RT".to_vec(), Object::name(if v.eq_ignore_ascii_case("group") { "Group" } else { "R" })); + } + if let Some(v) = node.attribute("icon") { + d.set(b"Name".to_vec(), Object::name(v)); + } + let flags: i64 = node + .attribute("flags") + .unwrap_or("print") + .split(',') + .filter_map(|f| FLAGS.iter().find(|(_, s)| s.eq_ignore_ascii_case(f.trim())).map(|x| x.0)) + .fold(0, |bits, flag| bits | flag); + d.set(b"F".to_vec(), Object::Int(flags)); + if let Some(c) = node.attribute("color").and_then(parse_hex) { + d.set(b"C".to_vec(), arr(&c)); + } + if let Some(c) = node.attribute("interior-color").and_then(parse_hex) { + d.set(b"IC".to_vec(), arr(&c)); + } + if let Some(o) = node.attribute("opacity").and_then(|o| o.parse::().ok()) { + d.set(b"CA".to_vec(), Object::Real(o.clamp(0.0, 1.0))); + } + if let Some(w) = node.attribute("width").and_then(|w| w.parse::().ok()) { + let mut bs = Dict::new(); + bs.set(b"W".to_vec(), Object::Real(w.max(0.0))); + bs.set(b"S".to_vec(), Object::name("S")); + d.set(b"BS".to_vec(), Object::Dict(bs)); + } + if let Some(q) = node.attribute("coords") { + d.set(b"QuadPoints".to_vec(), arr(&csv(q))); + } + if let (Some(s), Some(e)) = (node.attribute("start"), node.attribute("end")) { + let (s, e) = (csv(s), csv(e)); + if s.len() == 2 && e.len() == 2 { + d.set(b"L".to_vec(), arr(&[s[0], s[1], e[0], e[1]])); + } + } + if node.attribute("head").is_some() || node.attribute("tail").is_some() { + d.set( + b"LE".to_vec(), + Object::Array(vec![Object::name(node.attribute("head").unwrap_or("None")), Object::name(node.attribute("tail").unwrap_or("None"))]), + ); + } + if let Some(cl) = node.attribute("callout").map(csv).filter(|c| c.len() == 4 || c.len() == 6) { + d.set(b"CL".to_vec(), arr(&cl)); + if let Some(rd) = node.attribute("fringe").map(csv).filter(|r| r.len() == 4) { + d.set(b"RD".to_vec(), arr(&rd)); + } + d.set(b"IT".to_vec(), Object::name(node.attribute("intent").unwrap_or("FreeTextCallout"))); + // A callout's /LE is one name, not the head/tail pair of a line. + match node.attribute("head") { + Some(h) => d.set(b"LE".to_vec(), Object::name(h)), + None => { + d.remove(b"LE"); + } + } + } + for child in node.children().filter(|c| c.is_element()) { + match child.tag_name().name() { + "contents" => { + d.set(b"Contents".to_vec(), PdfString::text(&child.text().unwrap_or("").replace("\r\n", "\r").replace('\n', "\r"))); + } + "defaultappearance" => { + d.set(b"DA".to_vec(), PdfString::literal(child.text().unwrap_or("").as_bytes().to_vec())); + } + "inklist" => { + let list: Vec = child + .children() + .filter(|g| g.has_tag_name("gesture")) + .map(|g| arr(&g.text().unwrap_or("").split(';').flat_map(|p| csv(p).into_iter()).collect::>())) + .collect(); + d.set(b"InkList".to_vec(), Object::Array(list)); + } + "vertices" => { + // Whole x,y pairs only; a stray trailing number is dropped. + let mut v = csv(child.text().unwrap_or("")); + v.truncate(v.len() - v.len() % 2); + if !v.is_empty() { + d.set(b"Vertices".to_vec(), arr(&v)); + } + } + _ => {} + } + } + Some(d) +} + +/// Put an annotation dictionary on a page, replacing one with the same `/NM`. Returns its +/// reference. +/// `/F` bits an imported file may not set on a redaction mark: Invisible, Hidden, NoView — so a +/// mark it brings in is always drawn for the user to review before content is removed. +const REDACT_UNSEEN: i64 = 1 | 2 | 32; + +/// `/F` Locked (ISO 32000-2 Table 167): the user protected this annotation from change. +pub(crate) const LOCKED: i64 = 128; + +/// An existing annotation an import must never replace, move or unlock: one the user locked, a +/// redaction mark (replacing it would drop the region the user marked to remove), or a form +/// widget or field (replacing it would set the field's value and policy around the form setter). +fn import_protected(doc: &Document, x: &Dict) -> bool { + let flags = x.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0); + flags & LOCKED != 0 || matches!(x.name(b"Subtype"), Some(b"Redact" | b"Widget")) || x.contains(b"FT") +} + +pub(crate) fn place(doc: &mut Document, page_ref: ObjRef, mut d: Dict) -> Result { + // A redaction mark the import carries is always made visible: clear any Invisible/Hidden/NoView + // flag so it cannot remove content the user never sees. + if d.name(b"Subtype") == Some(b"Redact".as_slice()) { + let f = d.int(b"F").unwrap_or(0); + if f & REDACT_UNSEEN != 0 { + d.set(b"F".to_vec(), Object::Int(f & !REDACT_UNSEEN)); + } + // Its review outline is drawn opaque, and from the area it removes: applying a mark + // without usable /QuadPoints removes its /Rect, which the outline is then drawn from. + d.remove(b"CA"); + d.remove(b"ca"); + let quads_drawable = d.get(b"QuadPoints").and_then(Object::as_array).is_some_and(|q| { + let v: Vec = q.iter().filter_map(Object::as_f64).filter(|v| v.is_finite()).collect(); + let (quads, rest) = v.as_chunks::<8>(); + v.len() == q.len() && rest.is_empty() && !quads.is_empty() && quads.iter().all(quad_has_area) + }); + if !quads_drawable { + // Quads the outline can't draw, or any quad that encloses no area, are replaced by the + // /Rect's, or dropped when there is no usable /Rect either; such a mark then removes + // nothing. + match rect_strict(doc, d.get(b"Rect")) { + Some([x0, y0, x1, y1]) => { + let (l, r, b, t) = (x0.min(x1), x0.max(x1), y0.min(y1), y0.max(y1)); + d.set(b"QuadPoints".to_vec(), arr(&[l, t, r, t, l, b, r, b])); + } + _ => { + d.remove(b"QuadPoints"); + } + } + } + } + let nm = d.get(b"NM").and_then(|n| n.as_string().map(|s| s.to_text())); + let annots: Vec = doc + .get(page_ref) + .as_dict() + .and_then(|p| p.get(b"Annots").cloned()) + .map(|a| doc.resolve(&a)) + .and_then(|a| a.as_array().cloned()) + .unwrap_or_default(); + let mut kept = Vec::new(); + let mut removed: Vec = Vec::new(); + // Never let a same-name import replace a protected annotation (`import_protected`): keep it, + // and add the imported dict beside it under no name of its own. + let mut clash = false; + for a in annots { + let existing = a.as_ref().and_then(|r| doc.get(r).as_dict().cloned()); + let same = nm.is_some() && existing.as_ref().and_then(|x| x.get(b"NM").and_then(|n| doc.resolve(n).as_string().map(|s| s.to_text()))) == nm; + if same && existing.as_ref().is_some_and(|x| import_protected(doc, x)) { + clash = true; + kept.push(a); + } else if same { + removed.extend(a.as_ref()); + } else { + kept.push(a); + } + } + if clash { + d.remove(b"NM"); + } + // The replaced note's pop-up goes with it; otherwise it stays on the page pointing at a note that is no longer there. + kept.retain(|a| { + let Some(x) = a.as_ref().map(|r| doc.get(r)).and_then(|o| o.as_dict().cloned()) else { return true }; + x.name(b"Subtype") != Some(b"Popup".as_slice()) || x.reference(b"Parent").is_none_or(|p| !removed.contains(&p)) + }); + // Keep the replaced comment's identity: replies and review statuses not included in a + // partial import still point at it through /IRT. Allocating a new object would detach + // those comments and leave their parent outside the page's annotation list. + let r = match removed.first().copied() { + Some(r) => { + doc.set(r, Object::Dict(d)); + r + } + None => doc.add(Object::Dict(d)), + }; + // Our own appearance where we can draw one; others keep none (viewers draw a default). + let _ = pdfcraft_annot::set_appearance(doc, r); + kept.push(Object::Ref(r)); + doc.update_dict(page_ref, |p| p.set(b"Annots".to_vec(), Object::Array(kept)))?; + Ok(r) +} + +/// A pop-up window for the comment `parent`, on its page. +pub(crate) fn add_popup(doc: &mut Document, page: ObjRef, parent: ObjRef, rect: &[f64], open: bool) -> Result<(), DataError> { + let mut p = Dict::new(); + p.set(b"Type".to_vec(), Object::name("Annot")); + p.set(b"Subtype".to_vec(), Object::name("Popup")); + p.set(b"Rect".to_vec(), arr(rect)); + p.set(b"Parent".to_vec(), Object::Ref(parent)); + p.set(b"Open".to_vec(), Object::Bool(open)); + let pr = doc.add(Object::Dict(p)); + doc.update_dict(parent, |d| d.set(b"Popup".to_vec(), Object::Ref(pr)))?; + doc.update_dict(page, |pd| { + if let Some(Object::Array(a)) = pd.get_mut(b"Annots") { + a.push(Object::Ref(pr)); + } + })?; + Ok(()) +} + +pub(crate) fn import_xfdf(doc: &mut Document, text: &str) -> Result { + check_form_xml_depth(text)?; + let xml = parse_form_xml(text).map_err(|e| DataError::Malformed(e.to_string()))?; + let root = xml.root_element(); + if root.tag_name().name() != "xfdf" { + return Err(DataError::UnknownFormat); + } + let mut report = Report::default(); + let pages: Vec = pdfcraft_model::pages(doc).iter().map(|p| p.obj).collect(); + // Comments: parents first, then replies (whose /IRT needs the parent's reference). + let mut by_name: HashMap = HashMap::new(); + let mut later: HashMap = HashMap::new(); + if let Some(annots) = root.children().find(|c| c.has_tag_name("annots")) { + for node in annots.children().filter(|c| c.is_element()) { + let tag = node.tag_name().name().to_ascii_lowercase(); + let Some((subtype, _)) = SUBTYPES.iter().find(|(_, t)| *t == tag) else { continue }; + let Some(page) = node.attribute("page").and_then(|p| p.parse::().ok()).and_then(|p| pages.get(p).copied()) else { continue }; + let Some(d) = annot_from_xml(node, subtype, page) else { continue }; + let r = place(doc, page, d)?; + // A later same-name record replaces this comment, including its reply relation. + later.remove(&r); + if let Some(nm) = node.attribute("name") { + by_name.insert(nm.to_string(), r); + } + if let Some(irt) = node.attribute("inreplyto") { + later.insert(r, irt.to_string()); + } + // A pop-up window. + if let Some(pop) = node.children().find(|c| c.has_tag_name("popup")) + && let Some(rect) = pop.attribute("rect").map(csv).filter(|r| r.len() == 4) + { + add_popup(doc, page, r, &rect, pop.attribute("open") == Some("yes"))?; + } + report.comments += 1; + } + } + for (r, irt) in later { + if let Some(parent) = by_name.get(&irt).copied().or_else(|| find_by_name(doc, &irt)) { + doc.update_dict(r, |d| d.set(b"IRT".to_vec(), Object::Ref(parent)))?; + } + } + // Fields. + if let Some(fields) = root.children().find(|c| c.has_tag_name("fields")) { + let mut values = Vec::new(); + walk_xfdf(fields, "", &mut values, 0)?; + apply_values(doc, &values, &mut report); + } + Ok(report) +} + +pub(crate) fn find_by_name(doc: &Document, nm: &str) -> Option { + for p in pdfcraft_model::pages(doc) { + for a in p.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default() { + if let Some(r) = a.as_ref() + && doc.get(r).as_dict().and_then(|d| text_of(doc, d, b"NM")).as_deref() == Some(nm) + { + return Some(r); + } + } + } + None +} diff --git a/docs/architecture.md b/docs/architecture.md index 40084794b4..77e141d0de 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -71,8 +71,11 @@ The PDF object graph *is* the model (`crates/engine/README.md` §The editing mod ## File I/O - PDF in: any revision chain, xref tables or streams, hybrid files, damaged files (repaired and - logged), all standard-security revisions. Image codecs (DCT, JPX, JBIG2, CCITT) decode through - the bootstrap renderer. + logged), all standard-security revisions. Image codecs (DCT, JPX) decode through the bootstrap + renderer; CCITT and JBIG2 decode through `pdfcraft-filters` (`decode_ccitt_within`, + `decode_jbig2_within`, behind `ImageBudget::decode`, the only way to decode an image outside + the renderer, which shares one allowance across an operation's images) for export, redaction + and the optimizer, and through the renderer for display. Export ▸ Images decodes JPEG itself. - PDF out: incremental or full saves, object streams, re-encryption, resource de-duplication when combining. - Other formats: images and text in (`create`); PNG/JPEG/TIFF, text, RTF, Word and HTML out diff --git a/docs/cli-batch.md b/docs/cli-batch.md index ffe0356cf5..ad2982bb14 100644 --- a/docs/cli-batch.md +++ b/docs/cli-batch.md @@ -27,8 +27,11 @@ new engine importers become available here automatically. Without `convert`, only `.pdf` inputs are processed. Unsupported extensions are reported as skipped. - `optimize` uses Reduce File Size settings: JPEG quality 60, resampling to 150 ppi - above 225 ppi, plus the existing document cleanup. `--ppi` (9–1600) sets the target - and a threshold of 1.5 times that value; `--quality` (1–100) sets JPEG quality. + above 225 ppi (black-and-white images to 300 ppi above 450 ppi, stored as CCITT + Group 4 when smaller), plus the existing document cleanup. `--ppi` (9–1600) sets the + target and a threshold of 1.5 times that value; black-and-white images go to the + larger of `--ppi` and 300 ppi, so a lower value keeps scanned text legible and a + higher one keeps their resolution. `--quality` (1–100) sets JPEG quality. These settings can lose image detail. A candidate that is not smaller retains the bytes entering that step. This is not an images-only or lossless preset. - `ocr` uses the installed engine and its supported languages. `--dpi` (72–600, diff --git a/docs/contributors.md b/docs/contributors.md index a1b0bcba13..fa7e988611 100644 --- a/docs/contributors.md +++ b/docs/contributors.md @@ -1,16 +1,14 @@ # Contributors in the About window **About ▸ Contributors** credits everyone who contributed to PdfCraft, and **About ▸ Models** credits -the AI models named in `Co-Authored-By` trailers. This follows the shared craftrules standard -[`standards/contributors.md`](https://github.com/storytold/craftrules/blob/main/standards/contributors.md); -this page is the local copy of the decision. +the AI models named in `Co-Authored-By` trailers. ## Decision - Two files, both **compiled into the binary** by the UI crate's `build.rs` (nothing is read from disk or the network at run time; the web build has them too): - [`contributors/contributors.json`](../contributors/contributors.json): commit stats per GitHub - username. **Generated** by `craftrules/scripts/contributors.py`; never hand-edit. + username. - [`contributors/people.toml`](../contributors/people.toml): the names people chose for themselves. **Each contributor adds only their own entry**, in their own PR. - Each contributor gets one line: GitHub username (always), display name (opt-in, or their synced @@ -36,20 +34,5 @@ display_name = "Nickname" sync_github_name = true # use the name on your public GitHub profile as display name ``` -or, with craftrules checked out next to `craft-apps/`: - -```sh -python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "Your Name" --sync-github-name -``` - -Names show after the next refresh: the script checks that each entry was committed by its own -user (or by a maintainer with a `consent_source`). Delete your entry to remove your names. - -## Refreshing (maintainers) - -```sh -python3 ../../craftrules/scripts/contributors.py . # regenerate + verify; needs git, Python 3.11+, gh -python3 ../../craftrules/scripts/contributors.py --check . # verify people.toml only -``` - -Commit the updated `contributors/contributors.json`. Git author names and emails are never recorded. +Names show for entries committed by their own user (or by a maintainer with a `consent_source`). +Delete your entry to remove your names. Git author names and emails are never recorded. diff --git a/docs/development.md b/docs/development.md index e50907ba56..8cb132d9f8 100644 --- a/docs/development.md +++ b/docs/development.md @@ -154,7 +154,7 @@ copies retain their current preferences locations. | `PDFCRAFT_TITLEBAR` | Linux: `app` draws PdfCraft's own title bar (window buttons in the tab strip, in the order of GNOME's `button-layout`, and resize edges); `system` keeps the frame winit or the desktop draws; unset, PdfCraft draws its own when the Wayland compositor draws no frames (GNOME), and leaves it to the desktop otherwise (KDE, Sway, Hyprland, X11). Where PdfCraft draws it, Preferences ▸ Use system title bar switches back to winit's frame without a restart; `system` also hides that option | | `PDFCRAFT_BUILD_LABEL` | Build time: a label for a local or patched build (letters, digits, inner dots), shown after the version in `--version`, About and the log (`0.7.1 (titlebar)`). `packaging/linux/package.sh` also puts it in the file names and the .rpm/.deb release (`1.titlebar`), which upgrades over the release of the same version | | `CRAFT_FONTS_DIR` | Build time: a [craft-fonts](https://github.com/storytold/craft-fonts) checkout to embed (Japanese fonts) | -| `PDFCRAFT_SYSTEM_FONTS` | `0` stops the desktop app from using an installed font for characters its embedded fonts lack (`cargo xtask screenshots` sets it) | +| `PDFCRAFT_SYSTEM_FONTS` | `0` disables installed-font fallback for both interface text and non-embedded CJK page text in the desktop viewer, Combine thumbnails and accessibility figure previews (`cargo xtask screenshots` sets it). Document fallback is display-only: exports, printing, OCR and automation retain the bundled-font path. | | `FONTCONFIG_FILE` | On Linux/Unix, the Fontconfig configuration file used to discover installed UI fallback fonts (default: `/etc/fonts/fonts.conf`); relative filenames are resolved under `FONTCONFIG_PATH` | | `FONTCONFIG_PATH` | Directory for a relative `FONTCONFIG_FILE` (default: `/etc/fonts`; the first entry of a search path is used) | diff --git a/docs/egui-037-migration-plan.md b/docs/egui-037-migration-plan.md index 2871a57f7c..5866330f28 100644 --- a/docs/egui-037-migration-plan.md +++ b/docs/egui-037-migration-plan.md @@ -2,7 +2,7 @@ The local integration includes main `d764a8ebca2dc289841b34ea3048f613a9869bbb` and pins craft-ui, craft-quit and craft-print to published `42e30cd5b3f4b114270dd053fccbc33bedc47f68`. The egui, winit and independent craft-docpath pins are unchanged. Its `Cargo.lock` SHA-256 is `3e0c10576c8584eb1f45a27908177e7c65bb83f8e855bf3a5429edd4e7268c80`. -Main replaced the floating docking workspace with fixed shared side panes in `crates/ui-egui/src/side_panels.rs`. `PaneHost` owns the Tools and inspector columns; `Rail` owns the fixed navigation rail and footer. The integration retains the 0.37 ID, text alignment, capture, IME and accessibility adaptations. The former `docking.rs`, `rail-nav` panel and read-mode inspector panel no longer exist. Floating windows, tab groups and drag/redock operations from the earlier design are not capabilities of this integrated UI. Their historical acceptance items below do not establish current behavior. +Main replaced the floating docking workspace with fixed shared side panes in `crates/ui-egui/src/panels/side_panels.rs`. `PaneHost` owns the Tools and inspector columns; `Rail` owns the fixed navigation rail and footer. The integration retains the 0.37 ID, text alignment, capture, IME and accessibility adaptations. The former `docking.rs`, `rail-nav` panel and read-mode inspector panel no longer exist. Floating windows, tab groups and drag/redock operations from the earlier design are not capabilities of this integrated UI. Their historical acceptance items below do not establish current behavior. Focused scroll, zoom and RTL palette regressions passed. Removing the production fixes while retaining the tests reproduced double scrolling, double zooming and failed reordered-word RTL search; both source files were then restored byte-for-byte. Independent source review accepted the integration. Full combined-tree CI and the separate web WASM check passed with required external oracles: 5,190 passing test executions, zero failures and 22 ignored across 89 summaries. The first attempt stopped at the storage reservation guard; the successful rerun used an increased reservation. The 5,190 result comes from managed run `egui037-pdf-combined-ci-20261025-1340` and qualifies the integration of main `d764a8ebca2dc289841b34ea3048f613a9869bbb` with `Cargo.lock` SHA-256 `3e0c10576c8584eb1f45a27908177e7c65bb83f8e855bf3a5429edd4e7268c80`, published as `fbfad2039c650a02b39ceee5c3f0e4d6fd4b3f74`. The older 5,183-pass result covered commit `9032aeb35f7abe7777f66d4b835fd461a865c516`, integrating main `71b47b51`; it is a separate checkpoint and is not evidence for the current tree. Both runs recorded 22 ignored executions across 89 summaries. @@ -165,7 +165,7 @@ Docking is `crates/ui-egui/src/docking.rs`; `DockArea::new(egui::Id::unique("pdf ### 2.7 Window frame, decorations and restoration -`apps/pdfcraft/src/main.rs` owns native options, app creation and GPU setup. `apps/pdfcraft/src/decorations.rs` decides whether GNOME Wayland leaves the frame to the app. `crates/ui-egui/src/window_controls.rs` draws the app frame and resize edges, and `window_state.rs` repairs a 64×64 restored window. +`apps/pdfcraft/src/main.rs` owns native options, app creation and GPU setup. `apps/pdfcraft/src/decorations.rs` decides whether GNOME Wayland leaves the frame to the app. `crates/ui-egui/src/platform/window_controls.rs` draws the app frame and resize edges, and `window_state.rs` repairs a 64×64 restored window. `craft-window` owns `Frame`, `Layout` and `Button`; `window_controls.rs` currently has distinct duplicate types and its own GNOME `button-layout` parser. The migration makes `window_controls` re-export `craft_window::{Button, Layout}` and removes the duplicate parser. `decorations::app_drawn` uses `craft_window::Choice::probe(Prefix::new("PDFCRAFT"))`; `PDFCRAFT_TITLEBAR` accepts `app`, `system` and `desktop` (the last two force the desktop frame), matching craft-window. PdfCraft still applies `with_decorations(false)` only when it will draw its own header bar, and the runtime “Use system title bar” preference still toggles `ViewportCommand::Decorations`; craft-window decides the startup frame, not the runtime preference. The probe is called once at startup and never from `App::ui`. diff --git a/docs/form-performance.md b/docs/form-performance.md index 1ef72dfc6d..637fd94986 100644 --- a/docs/form-performance.md +++ b/docs/form-performance.md @@ -6,7 +6,7 @@ not fresh measurements on upstream main. No private document is needed for the b ## Architecture -- `crates/ui-egui/src/forms_ui.rs` queues `Edit::SetFieldValue` for a text commit or checkbox +- `crates/ui-egui/src/tools/forms_ui.rs` queues `Edit::SetFieldValue` for a text commit or checkbox toggle. `crates/automation/src/forms.rs::form_fill` uses the same edit API. - `crates/engine/src/session.rs::Session::apply` runs edits on a copy, preserving rollback and undo. `run_edit` (`crates/engine/src/edit.rs`) calls `pdfcraft_forms::set_value_with` with an diff --git a/docs/gaps.md b/docs/gaps.md index 56ec3ae748..4f35e651d1 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,9 +1,12 @@ # Where PdfCraft falls short of Acrobat Pro > **Last reviewed:** 2026-10-25 · **Last updated:** 2026-10-25 · **Change:** minor (gaps 5 and 14 current state: native release Hans face via LXGW WenKai Lite; Arabic logical catalog, explicit RTL direction and mirrored stock chrome, with native/browser/IME workflows unqualified; no readiness re-estimate) +> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (gap 3: #2517 a blank line stays a paragraph break; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-19 · **Change:** minor (gap 2: composite fonts with `/ToUnicode /Identity-H` read as Unicode, hayro-interpret patch 44; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** minor (gap 6: #2662 redaction audit counts a cover over one word of a long line; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-22 · **Last updated:** 2026-10-22 · **Change:** minor (gap 3: #2517 a partial rewrite inside one text object keeps the next paragraph; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (gap 3: #2517 a partial rewrite keeps a kept row inside the paragraph and leaves the next marked region closed; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (gap 12: reading order is scored, not just word F1) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M1: raw-deflate fallback preserves decoding-limit errors; 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 (gap 14: Vietnamese catalog shipped; Hindi/Indonesian/Korean and Arabic RTL remain) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -100,7 +103,13 @@ update the numbers. Legacy non-SFNT previews, mixed-font/style contextual runs, structure-tree fidelity and reliable semantic grouping on every untagged PDF remain open. Replacing one line or paragraph no longer removes a distinct neighbour whose glyph box overlaps (#1316); a second drawing on the same - baseline is still replaced. Estimates are unchanged. + baseline is still replaced. A blank line stays a paragraph break. An explicit `TL` is the + measured baseline gap, and growing a paragraph so its new row is one leading above the next + one does not absorb that paragraph (#2517). A partial rewrite of the last paragraph keeps a + source row inside that paragraph, and does not close a following marked region early. Two + paragraphs that share one text object keep that boundary when the first is rewritten. A heading + and smaller body lines (#1756) and cells in different columns (#1680, #2478) are still grouped + by the same geometry. A row that a list item wraps onto stays with that item. Estimates are unchanged. - **Missing:** robust in-place editing of text PdfCraft didn't write: subset fonts without the needed glyphs, CJK and right-to-left text, rotated text, lists, superscript/subscript, keeping @@ -404,6 +413,8 @@ update the numbers. | Date | Change | Summary | |---|---|---| | 2026-10-25 | minor | Gaps 5 and 14 current state: native release builds embed LXGW WenKai Lite for Simplified Chinese, while the web build has no Hans face; Arabic catalogs stay logical with explicit RTL label direction and mirrored stock chrome, while native/browser RTL input, IME and complex workflows remain unqualified. Estimates unchanged. | +| 2026-10-18 | minor | Gap 3: #2517 a partial rewrite keeps a kept source row in the paragraph and leaves the next marked region closed around its text. Estimates unchanged. | +| 2026-10-15 | minor | Gap 3: #2517 a blank line at ordinary leading stays a paragraph break. Estimates unchanged. | | 2026-10-19 | minor | Gap 2: a Type 0 font whose `/ToUnicode` is the name `/Identity-H` or `/Identity-V` (WSCAD) reads its codes as Unicode, so such pages have text to select, find and edit (hayro-interpret patch 44, `view.render-cid-fonts`). Estimates unchanged. | | 2026-10-15 | minor | Gap 21: the side-by-side view had already shipped; the Compare panel filters text changes and visual differences. Estimate 10–20 h → 8–16 h | | 2026-10-20 | minor | Gap 6 #3673: Character encoding accepts Identity-H/V with Adobe CJK collections, or a predefined non-Identity CMap; negative and indirect-reference regressions and a read-only automation test passed after failing before the fix. Per-glyph validation and embedded-font extraction remain separate. Scores and estimates unchanged. | diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index fcd0e8ac0d..da2b8d92b1 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -17,7 +17,7 @@ printing are counted in their feature areas). **Platforms ≈ 70% ready, 15–30 | Multi-threaded rendering | yes | yes (render pool, tiles, watchdog) | yes | yes | single thread | `view.render-priority` | | HiDPI / Retina | yes | yes | yes, per-monitor DPI v2 (#324, #725) | yes | yes | text blurry at 1080p/1440p (#730) | | Multiple monitors | yes, multiple windows | one window | one window; surfaces fit the GPU limit (#577) | one window | n/a | `view.multiple-windows` planned | -| Printers | all, PostScript, printer properties | CUPS via craft-print | Windows PDF raster spooler via craft-print (#756, PR #847) | CUPS via craft-print | the browser's print dialog (every printer the system knows): sheets drawn by PdfCraft at 150–300 dpi; copies, two-sided and colour set there | `crates/print/README.md`, `crates/ui-egui/src/web_print.rs` | +| Printers | all, PostScript, printer properties | CUPS via craft-print | Windows PDF raster spooler via craft-print (#756, PR #847) | CUPS via craft-print | the browser's print dialog (every printer the system knows): sheets drawn by PdfCraft at 150–300 dpi; copies, two-sided and colour set there | `crates/print/README.md`, `crates/ui-egui/src/workflows/web_print.rs` | | Scanners (TWAIN, WIA, ICA) | yes | none | none | none (SANE) | n/a | `ocr.scanner-acquire` planned | | Smart cards and USB tokens | yes (PKCS #11, CryptoTokenKit, CNG) | Keychain identities (software keys) | CNG store keys, with PIN prompts for cards (#742) | none (PKCS #11 planned) | none | `sign.pkcs11` planned | | Pen and touch | Touch mode, pen ink, Windows Ink | trackpad pinch zoom; ink from pointer, no pressure | ink from pointer, no pressure or touch mode | as macOS | pointer | touchpad scrolling 1:1 (#759, PR #868) | diff --git a/docs/integration-20261030.md b/docs/integration-20261030.md new file mode 100644 index 0000000000..2ee2dac6a7 --- /dev/null +++ b/docs/integration-20261030.md @@ -0,0 +1,93 @@ +# PdfCraft integration candidate, 2026-10-30 + +Base: 1d1cbc4ec92012767f5b48c92b451e15eb401c42 + +Validation: combined GitHub CI passed on bdf56209ce6a22980ef488eec92a55073f5a4e18 (Linux and Windows, run 41038860617). No fresh manual GUI pass has been performed. The subsequent architectural-guard additions require a fresh run. +All included source heads are preserved as merge parents, retaining original authorship. No upstream PR was merged. Conflicts were resolved after the user requested inclusion despite merge conflicts. + +## Review follow-up and withheld behavior + +Review 5712795161 identified combined-behavior gaps after the green baseline at 673178b. +The follow-up is pending fresh hosted validation; earlier results below are historical evidence. + +- Backdrop detection uses a shared strict 64 KiB decoding budget, including intermediate filter outputs, and caps content-stream entries. A fill stream executed more than once on the page is treated as ambiguous and is not replaced; source artwork stays untouched. +- Monochrome downsampling no longer guesses Indexed/custom color-space polarity. Those images may be recompressed losslessly but retain their resolution. A local 2-by-2 patch of mixed output samples conservatively retains the entire source image, even when white margins dominate it. +- **Withheld from #4180:** selected-row alignment of existing PDF paragraphs. The UI disables it for a text selection and the editing API explicitly refuses per-row alignment intent before mutation. Whole-paragraph alignment and the independently persisted alignments of added text remain available. Prior immediate-row tests are replaced by safe-refusal tests; no cross-session alignment guarantee is claimed for the withheld feature. +- The custom JBIG2 encoder still needs separate representative independent-decoder qualification. No codec corruption defect has been established by this review. No fresh native manual GUI acceptance pass is claimed. + +## Included +- [#4304](https://github.com/storytold/pdfcraft/pull/4304): M11: PDF Optimizer stores black-and-white images as CCITT Group 4 (optimize.mono-compression) (1b7164cab1fc187c5385c30f94449542fe284280) +- [#4335](https://github.com/storytold/pdfcraft/pull/4335): M11: black-and-white pictures stay one bit: Create PDF, the optimizer, and CCITT image export (210ba20fe69e701286bbfdbd924cfc01d9aaa9f4) +- [#4384](https://github.com/storytold/pdfcraft/pull/4384): M11: Monochrome Images ▸ Downsample: black-and-white images above 450 ppi are averaged down to 300, Group 4 scans included (1ad951e25bac86f77e75e060dcce34cc32a0f9a3) +- [#4404](https://github.com/storytold/pdfcraft/pull/4404): M11: the optimizer stores Group 3 fax as Group 4, and packs black-and-white RGB and two-entry indexed images to one bit (2d0a7b22411d4b61f130f6175cec918153858593) +- [#4179](https://github.com/storytold/pdfcraft/pull/4179): fix(ui): Ctrl+Z in an open paragraph undoes the document, not stale text (296b9300988fe3ef8d5bcdcb439705e80d6d7321) +- [#5300](https://github.com/storytold/pdfcraft/pull/5300): fix(ui): a press on the page belongs to what its one hit test finds, until the button is up (11bc8c56528fc3a820669d41c0eec29310cdad24) +- [#4719](https://github.com/storytold/pdfcraft/pull/4719): fix(comments): delete or edit one reply from its own menu, keeping the thread (adeb9c1cf2336c10639d8172325e708edf515856) +- [#4541](https://github.com/storytold/pdfcraft/pull/4541): M5: a moved comment is drawn where it goes while it is dragged (f0cd96e90bf696099b02201ac68f9ccb8a00c7d2) +- [#2815](https://github.com/storytold/pdfcraft/pull/2815): fix(edit): show backgrounds on pages that paint their own white backdrop (9aaac56417a2260338c7e7edb41615f1bb42b45e) +- [#4620](https://github.com/storytold/pdfcraft/pull/4620): feat(edit): Esc in an open paragraph selects its box, so Delete removes it (64fba7d177032170939861dc7eb51d8486a43264) +- [#3789](https://github.com/storytold/pdfcraft/pull/3789): Alt+Left / Alt+Right go back and forward through the view history (#3632) (08f3991ab9808b71fa015f22d49dbc4720044c78) +- [#3779](https://github.com/storytold/pdfcraft/pull/3779): Window title: mark unsaved changes, and set it on the web (#3640) (3850c9f52e073e51f8e4a1665b3ae36fae6f8e66) +- [#2960](https://github.com/storytold/pdfcraft/pull/2960): Keep lines with footnote references in paragraph reading order (#2929) (2a7e5bda268b3862b9b2329df16191473966c90e) +- [#5208](https://github.com/storytold/pdfcraft/pull/5208): M10: export a PDF to Markdown (#1595) (20d396d180a33139908a3a1fad6178ab77b90eff) +- [#4596](https://github.com/storytold/pdfcraft/pull/4596): fix(organize,print): join /Contents array pieces with a space (#3502) (d58d121170f2d650196263492c6c54a89aad39b3) +- [#3636](https://github.com/storytold/pdfcraft/pull/3636): Content streams: step over a malformed token instead of ending the page (43337c7fd5f7b5123efcebcc84de35c394726e7a) +- [#4494](https://github.com/storytold/pdfcraft/pull/4494): M2: page_render and render_preview report the render warnings next to the PNG (#4340) (5ada1249e9cdb98a438e26f8fe53c82aba9b8cc1) +- [#4013](https://github.com/storytold/pdfcraft/pull/4013): fix(engine): generate Protect keys and salts with getrandom (#4000) (c580b550555837ad6f2c1aa4152bdb5cb96cf337) +- [#5134](https://github.com/storytold/pdfcraft/pull/5134): fix(ui): keep the All tools rows clear of the scrollbar (#5133) (0c5119791db7de268258b9d25f1286b82b563f8a) +- [#2133](https://github.com/storytold/pdfcraft/pull/2133): Fix Pages sidebar select-all keyboard shortcut (#2052) (7227eb68ab383e47413f1a980f340f5132cf4144) +- [#2635](https://github.com/storytold/pdfcraft/pull/2635): Allow typed font sizes and Fill & Sign text colours (4b319bb3835709626c663b8d41b72d2f9574acc0) + +## Added after conflict resolution +- [#4320](https://github.com/storytold/pdfcraft/pull/4320): M11: lossless JBIG2 for black-and-white images (optimize.mono-compression) (e18d1bfed38272a33fad1b79b7452494a9f4dfd6). +- [#4805](https://github.com/storytold/pdfcraft/pull/4805): M11: JBIG2 symbol coding for black-and-white images (optimize.mono-compression) (c7f39a4d9a341dcbdacaf3bc131ea2825f030b13). +- [#4325](https://github.com/storytold/pdfcraft/pull/4325): M6: calculation checks: formulas reading missing fields, scripts building names, shown on the Calculation order page (ebad7f849d9dc04f8aa819efd4cfb06460de3d79). +- [#4180](https://github.com/storytold/pdfcraft/pull/4180): M7: align the selected lines of a paragraph or text box on their own (5a86f35210ca98adb23ba53ff9d0871593b0cfdb). +- [#1952](https://github.com/storytold/pdfcraft/pull/1952): feat(forms): edit checkbox and radio export values (#231) (e19f9646906df51c287f9b049feb8199cd645996). +- [#2529](https://github.com/storytold/pdfcraft/pull/2529): M7: keep a blank line as a paragraph break (#2517) (55a933800b94b89c0caae001ec48609cd5f70256). + +Additional previously reviewed parity fixes: #2133, #2635 and #1952. +Excluded held PRs #4648, #2877 and #3762 were not included. +Source reviews and individual hosted CI do not establish combined-build safety; the integration gate must pass. + +Integration-specific changes: preserve hard/soft wrap metadata together with per-row alignment; share CCITT/JBIG2 export paths with the 32 MiB globals cap retained; keep downsampling and RGB ownership guards with JBIG2 encoding; retain all translation keys and both sets of regression tests. + +## Per-PR regression evidence + +Evidence: [Linux job 124006360624](https://github.com/FredApps/pdfcraft/actions/runs/41038860617/job/124006360624) on bdf56209ce6a22980ef488eec92a55073f5a4e18: 5,627 passed, 15 skipped. The Windows leg of the same run also passed. + +For each source-head diff above, the added `#[test]` function names were matched against PASS/SKIP entries in the combined Linux log. Every listed source regression was found passing, none skipped or missing. Counts overlap because stacked PRs carry their prerequisites; they must not be summed as distinct tests. This records source regression coverage, not manual feature acceptance. + +- #4304: 11/11 added source regression names passed; 0 missing, 0 skipped. +- #4320: 21/21 added source regression names passed; 0 missing, 0 skipped. +- #4325: 18/18 added source regression names passed; 0 missing, 0 skipped. +- #4335: 19/19 added source regression names passed; 0 missing, 0 skipped. +- #4384: 39/39 added source regression names passed; 0 missing, 0 skipped. +- #4404: 50/50 added source regression names passed; 0 missing, 0 skipped. +- #4805: 36/36 added source regression names passed; 0 missing, 0 skipped. +- #5300: 18/18 added source regression names passed; 0 missing, 0 skipped. +- #4719: 6/6 added source regression names passed; 0 missing, 0 skipped. +- #4541: 5/5 added source regression names passed; 0 missing, 0 skipped. +- #2815: 35/35 added source regression names passed; 0 missing, 0 skipped. +- #4620: 1/1 added source regression names passed; 0 missing, 0 skipped. +- #2529: 35/35 added source regression names passed; 0 missing, 0 skipped. +- #3789: 5/5 added source regression names passed; 0 missing, 0 skipped. +- #3779: 4/4 added source regression names passed; 0 missing, 0 skipped. +- #2960: 1/1 added source regression names passed; 0 missing, 0 skipped. +- #5208: 2/2 added source regression names passed; 0 missing, 0 skipped. +- #4596: 1/1 added source regression names passed; 0 missing, 0 skipped. +- #3636: 3/3 added source regression names passed; 0 missing, 0 skipped. +- #4494: 4/4 added source regression names passed; 0 missing, 0 skipped. +- #4013: 4/4 added source regression names passed; 0 missing, 0 skipped. +- #4180: 54/54 added source regression names passed; 0 missing, 0 skipped. +- #4179: 20/20 added source regression names passed; 0 missing, 0 skipped. +- #5134: the source diff adds no test function. Combined gates passed; no new manual scrollbar acceptance is claimed. +- #2133: 1/1 added source regression names passed; 0 missing, 0 skipped. +- #2635: 6/6 added source regression names passed; 0 missing, 0 skipped. +- #1952: 6/6 added source regression names passed; 0 missing, 0 skipped. + +The conflict-specific tests `wrapped_rows_keep_alignment_separate_from_hard_break_markers` and `black_and_white_images_become_jbig2` (including the CCITT-to-JBIG2 conversion/render assertions) passed in that same run. Those counts describe that baseline. In the review follow-up, unsupported existing-row alignment success tests are replaced by safe-refusal tests; the corresponding feature is withheld. Paragraph-boundary, field-calculation and button-export coverage is retained. + +## Protect dependency check + +The integration base main commit 1d1cbc4ec92012767f5b48c92b451e15eb401c42 already locks registry `getrandom` 0.4.3 (checksum 300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099). #4013 makes it a direct engine dependency rather than introducing an unrecorded package. The combined dependency-policy and asset gates passed. The fail-closed randomness regression also passed. diff --git a/docs/licensing-self-check.md b/docs/licensing-self-check.md new file mode 100644 index 0000000000..305d756ff7 --- /dev/null +++ b/docs/licensing-self-check.md @@ -0,0 +1,33 @@ +# Source licensing self-check + +Run `cargo xtask license-check` to validate the checked-out source, offline. The command +does not download assets, fetch models, inspect installers, or generate dependency notices. +It reuses `cargo xtask assets` for attribution, asset policy and locked dependency versions, +then checks the documents recorded in `LICENSES.toml`. + +Each document has a repository-relative source, a unique portable logical destination and +the SHA-256 of its audited text. Destinations are validation metadata, not instructions to +copy files into packages. Required project documents and every asset's `licence_file` must +be mapped. Every dependency-bundled asset must have a document mapping matching its existing +`ATTRIBUTION.toml` licence expression. Foxit has an explicit required document. Licence +texts for egui's default fonts and CMaps are retained in `licenses/`, with their pinned +upstream origins recorded in the manifest. Existing font and asset texts stay in place. + +Missing files, empty or invalid text, changed hashes, duplicate mappings, case-insensitive +destination collisions, parent-file conflicts and unsafe paths fail the check. Source +symlinks must resolve inside the checkout. OCR models need complete attribution and valid +hashes and destination names; the model binaries and downloaded licence texts need not be +present. The optional craft-fonts checkout remains governed by its own attribution and +the existing asset validator; this command does not audit that external checkout. + +When changing attribution, edit `ATTRIBUTION.toml`, run `cargo xtask assets --write`. Generated attribution consistency remains checked only +by the existing asset validator. When changing licence or notice text, re-audit +its provenance before updating the corresponding hash. Do not replace a licence with +guessed terms or update a hash merely to silence a failure. `LICENSES.toml` is validated +with unknown fields rejected. + +The existing Linux source-validation step in `.github/workflows/ci.yml` and `cargo xtask ci` +run `license-check` in place of `assets`; the asset validator runs once within that command. +This Stage 1 check establishes source-document consistency, not completed-package +compliance or legal sufficiency. Installer inspection, dependency-notice generation and +font-delivery packaging changes remain separate work. diff --git a/docs/localization-parity.md b/docs/localization-parity.md index fee2ba3e7b..dc0cbab4e5 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,5 +1,6 @@ # Localization parity +> **Last reviewed:** 2026-11-03 · **Last updated:** 2026-11-03 · **Change:** minor (Turkish: 2,867 entries, 0 missing in `cargo xtask i18n-report` on 2026-11-03; AI-assisted) > **Last reviewed:** 2026-10-25 · **Last updated:** 2026-10-25 · **Change:** minor (Persian (`fa`, 2,785 entries) added right-to-left like Arabic and Kurdish; Arabic current state corrected: catalogs stay logical, RTL labels take an explicit direction and stock chrome mirrors; native/browser RTL input, IME and complex workflows remain unqualified. Native release Hans face via LXGW WenKai Lite; release-pinned craft-fonts still has no Telugu face. Current union 2,798 keys. Scores and estimates unchanged) · **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 @@ -72,7 +73,7 @@ help/docs polish (catalog and script work are already done), not missing UI stri | Polish | `pl` | 2,611 entries | partial | yes | | Slovenian | `sl` | 2,536 entries | partial | no | | Greek | `el` | 2,545 entries | partial | no | -| Turkish | `tr` | 2,525 entries | partial | yes | +| Turkish | `tr` | 2,867 entries (0 missing in `cargo xtask i18n-report`, 2026-11-03) | partial; AI-assisted, judgement calls reviewed by a native speaker, full proofreading pending | yes | | Lithuanian | `lt` | 2,798 (100% of current key union) | partial; community native-speaker review ([#1401](https://github.com/storytold/pdfcraft/pull/1401)) | no | | Finnish | `fi` | 2,798 entries (every current key) | partial; AI-assisted, native proofreading pending | yes | | Albanian | `sq` | 2,524 entries | partial; not yet reviewed by a native speaker | no | @@ -93,6 +94,7 @@ Acrobat languages PdfCraft lacks: Danish, Dutch, Swedish, Hebrew. Vietnamese is | 2026-10-25 | minor | Added Persian (`fa`, 2,785 entries, all but 13 of the current 2,798-key union); right-to-left like Arabic and Kurdish, with logical entries reordered by egui 0.37's epaint; AI-assisted, not yet native-reviewed | | 2026-10-21 | minor | Brazilian Portuguese (`pt-br`) now covers all 2,674 distinct keys in the current catalog union; no help or engine/OS error-detail localization; twelve-language mean still rounds to ≈ 68% | | 2026-10-20 | minor | Spanish: the 143 keys the other catalogs had and `es.tsv` was missing; `es` now covers the whole 2,674-key union, like `lt` and `vi`. Restated Lithuanian against the current union (its 2,585 was measured on 2026-10-13) and said plainly in *How this was measured* that the union moves, so counts only compare within a date | +| 2026-11-03 | minor | Turkish: 2,867 entries after translating the 288 keys the translation gaps report listed (AI-assisted); measured with the report, not against the 2,798-key union of 2026-10-25, which predates keys added since | | 2026-10-19 | minor | Removed the 2026-10-13 status line and the 14-language summary that concurrent merges left beside the current ones; the summary keeps the Italian and Norwegian Bokmål measurement dates | | 2026-10-16 | minor | Czech: 2,674 entries after adding the keys the other catalogs had (the engine's sentences, weekday heads, certificate fields, shared buttons) and unifying "tag" as *tag* (not *značka*, which is a redaction mark) | | 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` | diff --git a/docs/localization.md b/docs/localization.md index e2ce23ef54..e73a149d1d 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -87,7 +87,7 @@ Error details that come from the operating system, and faults rather than mistak ## Adding a language 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. +2. Add one row to `LANGUAGES` in `crates/ui-egui/src/i18n.rs`: code, native name, catalog and plural rule. 3. Add an `ATTRIBUTION.toml` entry for the catalog (`kind = "translation"`, no `sha256`), then run `cargo xtask assets --write` (it regenerates `ATTRIBUTION.md` only) and `cargo xtask assets`. The Preferences dropdown, the system-language match, the catalog tests (format, duplicates, placeholders, plural forms, command ids) and the translation gaps report then pick it up. diff --git a/docs/pdf-spec-parity.md b/docs/pdf-spec-parity.md index 8cfe4be471..aefc959698 100644 --- a/docs/pdf-spec-parity.md +++ b/docs/pdf-spec-parity.md @@ -1,6 +1,8 @@ # PDF specification and standards parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (§14.3: custom properties edited in Document Properties) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> Integration update 2026-10-30: lossless JBIG2 generic and exact-copy symbol coding are included; no lossy substitution. Combined tests passed at bdf5620; the subsequent review fixes require fresh CI. + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (§7.4: JBIG2 decoding in `pdfcraft-filters`, so redaction clears JBIG2 scans; earlier, §7.4: redaction decodes CCITT fax images, Group 3 on bytes aside) · **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: @@ -21,7 +23,7 @@ are borrowed; PDF 2.0 additions and most subset standards are missing. **150–2 |---|---|---|---|---| | §7.2–7.5 | Syntax, objects, file structure, xref tables and streams, object streams, hybrid files, incremental updates | full | **own**, tolerant, repairs damaged files | `crates/cos`; corpus 958 files; fuzzed nightly | | §7.4 | Stream filters: Flate, LZW, ASCII85, ASCIIHex, RunLength, predictors | full | **own** | `crates/filters` | -| §7.4 | Image filters: DCT, JPX, JBIG2, CCITTFax | full | **borrowed** (decode only) | `core.image-filters` partial; own codecs M1.1/M1.2; no JBIG2/CCITT encoding (Optimizer) | +| §7.4 | Image filters: DCT, JPX, JBIG2, CCITTFax | full | **borrowed** (decode); CCITT Group 4 and lossless JBIG2 encoding | `core.image-filters` partial; own codecs M1.1/M1.2; CCITT decoding in `pdfcraft-filters` (`decode_ccitt`, through `hayro-ccitt`) for export, redaction and the Optimizer, which encodes Group 4 (`optimize.mono-compression`); JBIG2 decoding there too (`decode_jbig2`, through `hayro-jbig2`) for export and redaction; lossless JBIG2 generic-region or exact-copy symbol encoding; no lossy substitution | | §7.5.8 | Linearization | R W | R only | `core.linearization` planned | | §7.6.4 | Standard security handler R2–R6 (RC4 40/128, AES-128, AES-256), crypt filters, `/EncryptMetadata`, SASLprep | full | **own**, read and write | `crates/crypt`; written files open in Acrobat DC since #836 (#774: Acrobat needs the catalog outside object streams) | | §7.6.5 | Public-key security handler | full | none | `core.open-public-key`, `protect.certificate-encryption` planned | @@ -130,6 +132,10 @@ are borrowed; PDF 2.0 additions and most subset standards are missing. **150–2 | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | §7.4: JBIG2 decoding (`hayro-jbig2`) in `pdfcraft-filters`, which `Document::decoded_image` uses with the image's `/JBIG2Globals`: export reads JBIG2 behind other filters too, and redaction clears pixels in JBIG2 images instead of removing them whole (one whose page isn't its size still is) | +| 2026-10-26 | minor | §7.4: redaction clears pixels in CCITT fax images through the CCITT decoder, instead of removing them whole (Group 4 and Group 3 off bytes; Group 3 on bytes is still removed whole, as the bootstrap renderer may draw it as other rows than the decoder reads) | +| 2026-10-26 | minor | §7.4: CCITT decoding (`hayro-ccitt`) in `pdfcraft-filters`, for export and the Optimizer | +| 2026-10-26 | minor | §7.4: CCITT Group 4 encoding (`fax`) in the Optimizer | | 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 496ef4a672..72206e16f1 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -69,13 +69,20 @@ pinned in `release.yml` and builds with `CRAFT_FONTS_DIR` and `CRAFT_FONTS_REQUI embed its Japanese fonts and fail rather than ship without them (`AGENTS.md` §1.4). Bump the pin deliberately. -**OCR models:** every desktop package ships the Scan & OCR models (#103). The packaging scripts call -`stage_models` (`packaging/env.sh`; `package.ps1` on Windows), which runs `cargo xtask models` to -fetch every `kind = "model"` file in `ATTRIBUTION.toml` (verified by SHA-256) with its licence text -and an `ATTRIBUTION.txt`, straight into the package, so packaging needs the network. They go where -`pdfcraft_ocr::Models::dirs_beside_exe` looks: `models\` beside `pdfcraft.exe` (MSI, portable zip), -`PdfCraft.app/Contents/Resources/models` (macOS), and `share/pdfcraft/models` beside `bin/` -(deb, rpm, tar.gz, AppImage, Flatpak, FreeBSD). The web build doesn't include them yet. +**OCR models:** desktop packaging stages the Scan & OCR models (#103). +The packaging scripts call `stage_models` (`packaging/env.sh`; `package.ps1` on Windows), which runs `cargo xtask models` to fetch every `kind = "model"` file in `ATTRIBUTION.toml` and verify its SHA-256. +The current detection and recognition files are `text-detection.onnx` and `text-recognition.onnx`. +Each model ships with its licence text and the directory's `ATTRIBUTION.txt`, so packaging needs the network. +Upstream announced the legacy RTEN download URLs' brownout in [ocrs #282](https://github.com/robertknight/ocrs/issues/282) on 2026-10-27 and directs consumers to the ONNX models. + +Runtime discovery prefers a complete ONNX detection/recognition pair within each directory, then a complete legacy `text-detection.rten` / `text-recognition.rten` pair. +An incomplete ONNX update can therefore use an intact RTEN pair in the same directory. +Discovery never combines model formats or files from different directories. +Directory precedence stays the same: `$PDFCRAFT_MODELS`, the release-package locations beside the executable, then the source tree's `assets/models/`. +The first directory with a complete supported pair wins, even if an earlier legacy pair precedes a later ONNX pair. + +The release-package locations are `models\` beside `pdfcraft.exe` (MSI, portable zip), `PdfCraft.app/Contents/Resources/models` (macOS), and `share/pdfcraft/models` beside `bin/` (deb, rpm, tar.gz, AppImage, Flatpak, FreeBSD). +The web build does not include these models, and OCR remains limited to the Latin alphabet without accents. ### macOS @@ -111,7 +118,9 @@ neither the MSI nor the portable zip needs the Visual C++ redistributable. - Before packaging, it reads both executables' PE headers: the machine type must match `-Arch`, and `pdfcraft.exe` must be a GUI-subsystem program (no console window, #57) while `pdfcraft-cli.exe` stays a console program. -- `pdfcraft.wxs` (WiX v5) installs per machine into Program Files with a Start Menu shortcut, a +- `pdfcraft.wxs` (WiX v5) is a dual-scope package (per-user by default into + `%LOCALAPPDATA%\Programs\ArtCraft\PdfCraft`, per-machine into `Program Files\ArtCraft\PdfCraft` (`Program Files (x86)` for the 32-bit build), + see `packaging/windows/INSTALLATION.md`) with a Start Menu shortcut, a desktop shortcut (on by default) and an App Paths entry. Both shortcuts are plain links to `pdfcraft.exe`, not advertised MSI shortcuts (#107, #143). The MSI version is the numeric `X.Y.Z` (MSI has no pre-release field), and same-version upgrades are allowed so release candidates replace @@ -148,7 +157,7 @@ neither the MSI nor the portable zip needs the Visual C++ redistributable. - The portable zip holds both executables, the README, the licences, the OFL licence of each embedded craft-fonts family, and `portable.txt`. That marker beside `pdfcraft.exe` keeps the settings, logs, crash recovery and new digital IDs in `PdfCraftData\` next to the exe instead of - `%APPDATA%` and `%LOCALAPPDATA%` (`crates/ui-egui/src/portable.rs`). The MSI carries the same + `%APPDATA%` and `%LOCALAPPDATA%` (`crates/ui-egui/src/platform/portable.rs`). The MSI carries the same OFL notices; `-SkipBuild` requires `CRAFT_FONTS_DIR` so they can be staged, or `-BuiltWithoutCraftFonts` for binaries built without that input, and `test-msi.ps1` checks that the compiled MSI has OFL notice rows when expected (and none for a no-font build). diff --git a/docs/roadmap.md b/docs/roadmap.md index 40fa711c24..f376f7cb00 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,25 +1,63 @@ # PdfCraft roadmap: milestones and what's next +> **Last reviewed:** 2026-11-03 | **Last updated:** 2026-11-03 | **Change:** patch (M5 #5850 review: indirect typewriter intent and composer edits preserve outer whitespace; movement regression commits by outside click; estimates unchanged) + +> **Last reviewed:** 2026-11-03 | **Last updated:** 2026-11-03 | **Change:** patch (M5 #5850: Enter adds Fill & Sign line breaks; outside clicks commit, Escape cancels, and saved multiline appearances retain their text and height; estimates unchanged) + +> **2026-11-03, M7 / #5835 review:** active image crops retain secondary clicks over overlapping images; crop controls share measured layout with hit detection. Rotation, handle and page-edge regressions added; focused tests, scoped Clippy, formatting, layering and asset checks passed locally. Estimates unchanged. + +> **Last reviewed:** 2026-11-01 | **Last updated:** 2026-11-01 | **Change:** patch (M7 #5657: header/footer, watermark and background operations commit all selected pages together; later-page errors preserve marks, content, shared/inherited resources and pending edits; estimates unchanged) + +> **Last reviewed:** 2026-11-01 | **Last updated:** 2026-11-01 | **Change:** patch (M7, PR #2155 further review: inherited drawing/text state, affine image gestures, paste/selection ownership, no-op scripts and menu permissions; estimates unchanged) + +> **Last reviewed:** 2026-11-01 | **Last updated:** 2026-11-01 | **Change:** patch (M7, PR #2155 latest comments: explicit image-paste events, mixed-size field refresh, and documented mixed-script normalization with Undo; estimates unchanged) + +> **Last reviewed:** 2026-11-01 | **Last updated:** 2026-11-01 | **Change:** patch (M7, PR #2155 review follow-up: preserve image crop/tag/stream ownership, resize in page orientation, harden clipboard and text formatting, and reload installed fonts; estimates unchanged) + +> **Last reviewed:** 2026-10-31 | **Last updated:** 2026-10-31 | **Change:** patch (M2, PR #4172: GBK rasters compared with explicit Unicode and independently selected installed faces; wrong-glyph and wrong-family negative controls fail; estimates unchanged) +> **Last reviewed:** 2026-10-31 | **Last updated:** 2026-10-31 | **Change:** patch (M2, PR #4172: integrate main fa5f9f8c; retain CJK viewer and upstream raster regressions, restore explicit display_config imports; estimates unchanged) +> **Last reviewed:** 2026-10-28 · **Last updated:** 2026-10-28 · **Change:** patch (M2, PR #4172: integrate main 5af10276; retain both roadmap histories and viewer-only CJK font policy; estimates unchanged) +> **Last reviewed:** 2026-10-27 · **Last updated:** 2026-10-27 · **Change:** patch (M2, PR #4172: integrate main 1dd2737b; preserve viewer-only CJK fallback and regression coverage across engine, renderer and Combine module splits; estimates unchanged) +> **Last reviewed:** 2026-10-27 · **Last updated:** 2026-10-27 · **Change:** patch (M2, PR #4172: integrate main 9a9813bf and migrate viewer-only CJK configuration to the split engine modules; estimates unchanged) +> **Last reviewed:** 2026-10-26 · **Last updated:** 2026-10-26 · **Change:** patch (M2, PR #4172: document installed-font helpers and regression contracts; behavior and estimates unchanged) +> **Last reviewed:** 2026-10-26 · **Last updated:** 2026-10-26 · **Change:** patch (M2, PR #4172: print-preview font policy matches output, with isolated caches and GBK switching regressions; estimates unchanged) +> **Last reviewed:** 2026-10-26 · **Last updated:** 2026-10-26 · **Change:** patch (M2, PR #4172: reconcile current main and retain Japanese serif/weight selection alongside display-only CJK fallback; estimates unchanged) +> **Last reviewed:** 2026-10-29 | **Last updated:** 2026-10-29 | **Change:** minor (M7 #2517: a narrowed then widened paragraph keeps the typed spaces the wrap consumed, after save and reopen; estimates unchanged) +> **Last reviewed:** 2026-10-27 | **Last updated:** 2026-10-27 | **Change:** minor (M10: OCR model downloads use verified ONNX pairs with legacy discovery fallback; estimates unchanged) +> **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (M5: one reply in the Comments panel is edited, copied or deleted from its own "…" menu; estimates unchanged) +> **Last reviewed:** 2026-10-26 · **Last updated:** 2026-10-26 · **Change:** minor (M11: lossless JBIG2 stores each distinct mark once, as a symbol; estimates unchanged) > **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (M14 localization current state: Arabic logical catalog, explicit RTL label direction and mirrored stock chrome; native/browser RTL workflows unqualified. Native release Hans face via LXGW WenKai Lite; estimates unchanged) +> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-24 · **Change:** minor (M11: the optimizer stores Group 3 fax images as Group 4, and packs black-and-white RGB and indexed images that use only palette entries 0 and 1 to one bit; estimates unchanged) +> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-24 · **Change:** minor (M11: black-and-white images above 450 ppi are averaged down to 300 ppi by the optimizer; estimates unchanged) +> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-24 · **Change:** minor (M11: black-and-white pictures stay one bit when PDFs are created and optimized; CCITT images export; estimates unchanged) +> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-24 · **Change:** minor (M11: the PDF Optimizer can store black-and-white images as lossless JBIG2, and Export ▸ Images reads JBIG2 images; estimates unchanged) +> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M11: the PDF Optimizer and Reduce File Size store black-and-white images as CCITT Group 4 when smaller; estimates unchanged) +> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M6: the calculation order flags a formula that reads a field the form doesn't have and a script that builds the names it reads; the tools show each formula; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M7, #2636 partial: pasting added text boxes and images in the engine and through `content_copy`; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M11: image optimization accounts for page-local UserUnit; estimates unchanged) - +> > **Last reviewed:** 2026-10-23 | **Last updated:** 2026-10-23 | **Change:** minor (M10: Word table cells use the widths of their own grid columns; estimates unchanged) > **Last reviewed:** 2026-10-23 | **Last updated:** 2026-10-23 | **Change:** minor (M10: table spans retain text from covered slots in Office exports; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M10: image export preserves soft-mask Decode opacity ranges; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M10: vector printing preserves existing page XObjects when adding annotation appearances; estimates unchanged) +> +> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M2, PR #4172: CJK fallback in Combine thumbnails and figure previews; FangSong and subset-prefixed Founder family matching; estimates unchanged) +> +> **Last reviewed:** 2026-10-22 · **Last updated:** 2026-10-22 · **Change:** minor (M14, #3558: rebase guarded macOS per-file association recovery onto current main; estimates unchanged) > **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** minor (M12 #3673: Character encoding accepts Identity-H/V with Adobe CJK collections; estimates unchanged) - +> **Last reviewed:** 2026-10-22 · **Last updated:** 2026-10-22 · **Change:** minor (M7 #2517: a partial rewrite inside one text object closes its marker before the next paragraph; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M7 #2517: a blank line stays a paragraph break; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> > **Last reviewed:** 2026-10-20 | **Last updated:** 2026-10-20 | **Change:** minor (M10, #3532 partial: vector printing retains existing appearances with absent Subtype; estimates unchanged) - +> > **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** minor (M4: initial-view options resolve indirect boolean preferences; estimates unchanged) > **Last reviewed:** 2026-10-21 · **Last updated:** 2026-10-21 · **Change:** minor (M3, #3808: the Pages inspector has an independent 50–150% thumbnail size slider and buttons; selection and current-page following are retained, with headless ui.set coverage. Estimates unchanged.) - +> > **Last reviewed:** 2026-10-20 | **Last updated:** 2026-10-20 | **Change:** minor (M2, #3574: named form properties retain page origin and own dictionary boundaries; estimates unchanged) - +> > **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** minor (M3, #3541/#3620: comment hover follows individual marked lines and original annotation paint order; estimates unchanged) > **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** patch (PR #3348 review: retain short tracked punctuation in gap estimates; estimates unchanged) - +> > **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** minor (M2, #3601: missing spacing glyphs in non-embedded Japanese CID substitutes draw as blanks while PDF widths and Unicode stay unchanged; estimates unchanged) > **Last reviewed:** 2026-10-20 | **Last updated:** 2026-10-20 | **Change:** minor (M11 #3507: image merging preserves content stream boundaries; estimates unchanged) > **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** minor (M8, gap 6 #2662: the redaction audit counts a cover over one word of a long line; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -27,7 +65,7 @@ > **Last reviewed:** 2026-10-22 · **Last updated:** 2026-10-22 · **Change:** minor (M6: calculation scripts read their notation only from a comment, and a regular expression after a condition as one; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M5, PR #1971: bounded nested FDF export excludes source page/page-tree dictionaries; refreshed against upstream 4db6ac4a; estimates unchanged) > **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** patch (M2 #3086: dotted TOC leaders no longer hide word spaces; estimates unchanged) - +> > **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M3, PR #3101: optional gapless page display with translations in all 22 catalogs; organizer and comparison toggles corrected; estimates unchanged) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M14/#2383 GPU-limit candidate; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-20 - **Last updated:** 2026-10-20 - **Change:** minor (M6, #3502: form detection uses the existing content-stream boundary policy; estimates unchanged) @@ -42,7 +80,7 @@ > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M1: raw-deflate fallback preserves decoding-limit errors; refreshed upstream; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-15 · **Change:** minor (M2: objects in one large object stream are read in linear time; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-16; **Last updated:** 2026-10-16; **Change:** minor (M5 #2669, item 5: reply groups and page root counts per frame; estimates unchanged) - +> > **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-16 | **Last updated:** 2026-10-16 | **Change:** minor (M10 #1201 partial: lossless image Decode; 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) @@ -105,7 +143,7 @@ encryption and storage-write retry behavior are unchanged; this does not resolve ## Alpha gate -The stage standard's gate: Acrobat Pro's everyday core workflows must each work end to end on the +The alpha gate: Acrobat Pro's everyday core workflows must each work end to end on the main platform (macOS), with the work saved and reopened. Any `no`, or a `partial` that blocks the workflow, keeps PdfCraft pre-alpha; those rows are the alpha checklist. Assessed 2026-10-10 with the rule applied across the Crafting Apps: a row blocks only when a typical user cannot complete the @@ -150,6 +188,14 @@ Help ▸ Check for updates is manual only. ## Milestones +M2 update (2026-10-23, #234 / continuation of #1567): the desktop viewer uses installed CJK +faces for non-embedded CID fonts, including Windows GBK-EUC-H books. Windows Song/Hei/Kai +families are retained when installed, and reload retains the fallback. This is display-only; +export, printing, OCR, automation and machines without a candidate retain their limitations. +Print preview excludes installed faces and isolates its font cache from the viewer, matching +output font availability even after repeated view/print switching (2026-10-26, PR #4172). +The M2 readiness and remaining-work estimates below are unchanged. + "Plan size" is the original single-agent sizing. "Done" is the estimated share of that milestone's *acceptance criteria* that are met, not lines of code. "Remaining" is re-measured on 2026-10-10 at the measured rate (see [target-app-parity.md](target-app-parity.md#effort-and-calibration)); the @@ -159,10 +205,10 @@ total matches the parity estimate. |---|---|---|---|---|---| | 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). A full rewrite keeps unknown trailer entries, including objects reachable only from them (#1338). Fuzzing runs nightly (`xtask fuzz`). Raw-deflate fallback preserves decoding-limit errors, including through tolerant COS stream decoding. Done since: #2658: macOS atomic saves retain destination extended attributes, including Finder tags, and reject partial resource-fork reads after size changes. Metadata failures retain the original PDF and unsaved edit. 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. Forms without Resources use page Properties for ActualText and layer visibility; own dictionaries stop fallback. Oversized missing inline-image samples, including header and terminator overhead, no longer pin the content window (#3107 / PR #3438); normal large streamed images retain deterministic sample boundaries. Radial luminosity fades have synthetic coverage for the existing #2132 mask guard, including per-pixel references, antialiased clips, previews, scales and tiles; no additional production fix was needed. Dotted TOC leaders no longer inflate letter tracking or hide word spaces (#3086); phrase search and selection are covered. Short tracked ellipses retain their gap samples and remain searchable. Missing spacing glyphs in non-embedded Japanese CID substitutes render blank without changing PDF advances or Unicode (#3601). | +| 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. Forms without Resources use page Properties for ActualText and layer visibility; own dictionaries stop fallback. Oversized missing inline-image samples, including header and terminator overhead, no longer pin the content window (#3107 / PR #3438); normal large streamed images retain deterministic sample boundaries. Radial luminosity fades have synthetic coverage for the existing #2132 mask guard, including per-pixel references, antialiased clips, previews, scales and tiles; no additional production fix was needed. Dotted TOC leaders no longer inflate letter tracking or hide word spaces (#3086); phrase search and selection are covered. Short tracked ellipses retain their gap samples and remain searchable. Missing spacing glyphs in non-embedded Japanese CID substitutes render blank without changing PDF advances or Unicode (#3601). The desktop viewer can use installed CJK fonts, including Windows GBK Song/Hei/Kai/FangSong and subset-prefixed Founder names; Combine thumbnails and accessibility figure previews use the same display-only fallback, without changing export or print font policy (#234, continuation of #1567, review #4172). Print previews exclude installed faces and isolate their font cache; GBK regressions cover view/print switching for inline and pooled whole-page/tile rendering. Installed-font helpers and their regression contracts have function documentation. GBK regression rasters match explicit-Unicode references with independently selected installed faces; wrong-glyph and wrong-family negative controls fail. Integration with main fa5f9f8c retains viewer fallback across the split engine session modules, renderer tests and Combine thumbnail workers. | | 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). #3808: the Pages inspector offers a 50–150% thumbnail slider and buttons, also available through `ui.set`. 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). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #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. #3149: page-list changes discard render demand based on old geometry; actual raster dimensions are checked against the GPU limit before upload. Synthetic crash and continued-rendering regressions cover the replacement path. Save-prompt shortcuts yield to dialogs above them; stacked link-consent UI regression covers Escape and Return. Comment hover follows individual marked lines and original annotation paint order (#3541/#3620), independently of spatial inspection sorting; synthetic rotation, differing-upper-edge overlap and unchanged-document regressions. Optional page gaps join page edges without cropping PDF margins; current-document toggle, persisted default and UI control options are covered by viewer tests. Gap changes in Organize Pages preserve document scroll position, and both comparison panes follow menu and control choices. Repeated window resizes by an edge and moves by the tab strip on GNOME/Wayland work without a click in between. | +| 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). #3808: the Pages inspector offers a 50–150% thumbnail slider and buttons, also available through `ui.set`. 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). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #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. #3149: page-list changes discard render demand based on old geometry; actual raster dimensions are checked against the GPU limit before upload. Synthetic crash and continued-rendering regressions cover the replacement path. Save-prompt shortcuts yield to dialogs above them; stacked link-consent UI regression covers Escape and Return. Comment hover follows individual marked lines and original annotation paint order (#3541/#3620), independently of spatial inspection sorting; synthetic rotation, differing-upper-edge overlap and unchanged-document regressions. Optional page gaps join page edges without cropping PDF margins; current-document toggle, persisted default and UI control options are covered by viewer tests. Gap changes in Organize Pages preserve document scroll position, and both comparison panes follow menu and control choices. Repeated window resizes by an edge and moves by the tab strip on GNOME/Wayland work without a click in between. Optional --two-paged applies noncontinuous two-page layout to explicitly opened CLI files without changing defaults; explicit --layout wins. | | 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). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #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. Wrapped revision buttons stay above Binding in Document Properties (#2739); a synthetic UI test checks layout and saved revision content. | | 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. | @@ -177,7 +223,7 @@ total matches the parity estimate. | 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: replies thread by their `/IRT` reference rather than the parent's `/NM`, so files that repeat names per page (PyMuPDF) thread correctly, and the Comments header counts comments without their replies, as Acrobat does, with the reply count under it; the Comments panel groups replies and counts all page roots once per frame (#2669, item 5); 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 | +| 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; Enter adds line breaks in a growing typewriter editor, outside clicks commit, Escape cancels, and saved/headless appearances keep multiline text (#5850); indirect typewriter intent resolves before whitespace decisions and Fill & Sign classification, with comment listing/property lookup retaining direct Subtype, and composer edits retain typewriter text as written while other comments retain trimming; agent tools.
Done since: replies thread by their `/IRT` reference rather than the parent's `/NM`, so files that repeat names per page (PyMuPDF) thread correctly, and the Comments header counts comments without their replies, as Acrobat does, with the reply count under it; the Comments panel groups replies and counts all page roots once per frame (#2669, item 5); each reply in the Comments panel has its own "…" (Edit, Copy text, Delete): Delete takes that reply and the replies to it, the comment and its other replies stay, and the card stays selected; 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; nested FDF export uses lazy array owners and annotation positions, with retained entries budgeted before cloning under copied-graph limits and source `/Page` and `/Pages` dictionaries replaced by null at every copy depth); 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 | @@ -185,7 +231,7 @@ total matches the parity estimate. | 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; XFDF values retain text across XML comments and processing instructions. Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references.
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. CSV export preserves carriage returns in names and values. | | 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; XFDF values retain text across XML comments and processing instructions. XML/XFDF exports preserve tabs and line breaks in names and comment metadata. Refreshed against upstream `99941daa` (2026-10-15).
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. CSV export preserves carriage returns in names and values. | | 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; XFDF values retain text across XML comments and processing instructions. XML/XFDF exports preserve tabs and line breaks in names and comment metadata. Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities.
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. CSV export preserves carriage returns in names and values. | -| 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. Done since: #3187: Added-image automation normalizes extreme signed rotation angles before combining them with the current orientation; integer bounds, history, pixels, save/reopen and MCP have regressions. Done since: #2592: Styled fallback fonts batch up to 240 glyphs per actual face and retain source styles. Budget refusal and distinct same-BaseFont descriptor weights have mutation-checked regressions; coverage and complex mixed runs remain limited. 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; 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. Done since: #3187: Added-image automation normalizes extreme signed rotation angles before combining them with the current orientation; integer bounds, history, pixels, save/reopen and MCP have regressions. Done since: #2592: Styled fallback fonts batch up to 240 glyphs per actual face and retain source styles. Budget refusal and distinct same-BaseFont descriptor weights have mutation-checked regressions; coverage and complex mixed runs remain limited. Done since: #5657 page-mark operations are atomic across selected pages, including shared/inherited resources and existing unsaved edits. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks PR #2155 candidate: editor-owned crops preserve original clips through group moves; Arrange preserves added-item stream ownership and refuses unsafe scope/state/tag extraction; image handles and flips follow page orientation; general transforms and Unicode script toggles have headless tools. Clipboard, mixed-size formatting, Home pins and reloadable installed-font discovery have review regressions. Latest follow-up uses explicit image-paste events, refreshes the mixed-size display, and documents script normalization with Undo. Further review covers inherited Form text and mask chronology, scoped transform composition, affine resize/rotation pivots, paste/selection ownership, no-op scripts and page-menu permissions. Done since: #5835 a right-click on an image in crop mode keeps the crop and opens no menu, also over overlapping and rotated images. | | 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). #3212: Headers, Bates numbers, watermarks and added text stay visible after unfinished marked content, without revealing hidden content. Tracked editable text distinguishes repeated glyph spacing from word gaps, with synthetic paragraph/object regressions. Missing: editing existing text and images in place (the longest pole), watermark print/screen visibility and repeat | | 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; XFDF values retain text across XML comments and processing instructions.
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: Existing simple field-font encodings and Differences are respected when generating appearances; an unencodable value uses a separate Helvetica appearance font without changing the source font. Synthetic reopen regressions cover punctuation, subset fallback and multiline text. Exact font metrics/layout and broader Unicode fallback remain incomplete. | @@ -193,13 +239,13 @@ total matches the parity estimate. | 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), image/PDF watermarks | | M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.
Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Redaction cleans indirect and form structure references, preserving surviving form tags. Redaction retires inherited resource dictionaries, so a parent `/Pages` can't keep a removed XObject reachable; unredacted pages are left untouched. Image redaction removes alternate images and unsupported masks only on affected images. Changed marked-content spans lose replacement descriptions; untouched spans keep theirs. Hidden-text cleanup retires unused named descriptions across inherited/shared page and form resources (64 focused tests and scoped Rust 1.96 Clippy/wasm checks pass; publication CI pending). The redaction audit counts a filled cover that hides half of any one word of a long line (#2662), applied or not. Phone-pattern redaction also matches labelled Italian geographic numbers with `+39` and a slash after the area code (#3205 follow-up); unlabelled international slash numbers remain unsupported. Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding | | M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL.
Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites, page validation overlays separate from original signature artwork (baked Not Verified captions remain; #79 open), snapshot tabs that reuse instead of multiplying. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 | -| M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).
Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Lossless image export applies per-component Decode, including Indexed mapping (#1201 partial). Existing 16-bit precision and color-management limits remain. #3532 partial: vector printing retains existing appearances with absent Subtype through a print-owned Form copy; source artwork stays unchanged. #3120: Office export reads form XObjects 50 levels deep, as the renderer does, and up to 50 000 a page; pages past that are listed in `truncated_pages`. Word table cells use the grid columns they span, including cells after a merged cell. Done since: text in a table slot covered by another cell's span remains as paragraph text in Word, HTML, RTF and PowerPoint. Missing: OCR languages beyond Latin, editable-text OCR output, full Office layout fidelity/import. Done since: dotted-leader contents entries stay in separate complete paragraphs (#1757) #2156 candidate: last selected print paper persists across sessions; other print preferences unchanged. Image export applies soft-mask Decode opacity intervals before resizing, including indirect entries and inverted ranges. Vector printing gives annotation appearances unoccupied XObject names so existing page artwork survives. | -| M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 80–140 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Target-version compatibility is partial: newer content is not detected or converted. Content stream boundaries preserve comments, split operands and graphics state when image fragments merge. Image downsampling includes page-local `/UserUnit` scaling when measuring physical resolution. Done since: reject invalid image and soft-mask dimensions with checked sizing (#2178). Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA | +| M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).
Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Model staging fetches SHA-256-verified ONNX detection/recognition files after the upstream RTEN URL brownout; runtime discovery prefers complete ONNX pairs per directory, then complete legacy RTEN pairs, without mixing formats or changing directory precedence. Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Lossless image export applies per-component Decode, including Indexed mapping (#1201 partial). Existing 16-bit precision and color-management limits remain. #3532 partial: vector printing retains existing appearances with absent Subtype through a print-owned Form copy; source artwork stays unchanged. #3120: Office export reads form XObjects 50 levels deep, as the renderer does, and up to 50 000 a page; pages past that are listed in `truncated_pages`. Word table cells use the grid columns they span, including cells after a merged cell. Done since: text in a table slot covered by another cell's span remains as paragraph text in Word, HTML, RTF and PowerPoint. Missing: OCR languages beyond Latin, editable-text OCR output, full Office layout fidelity/import. Done since: dotted-leader contents entries stay in separate complete paragraphs (#1757) #2156 candidate: last selected print paper persists across sessions; other print preferences unchanged. Image export applies soft-mask Decode opacity intervals before resizing, including indirect entries and inverted ranges. Vector printing gives annotation appearances unoccupied XObject names so existing page artwork survives. | +| M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 80–140 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Target-version compatibility is partial: newer content is not detected or converted. Content stream boundaries preserve comments, split operands and graphics state when image fragments merge. Image downsampling includes page-local `/UserUnit` scaling when measuring physical resolution. Done since: reject invalid image and soft-mask dimensions with checked sizing (#2178); black-and-white images stored losslessly as CCITT Group 4 when smaller, averaged down to 300 ppi above 450 (halftones aside), Group 3 stored again as Group 4, and black-and-white RGB and indexed images that use only palette entries 0 and 1 packed to one bit. Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA | | 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. Character encoding accepts Identity-H/V with Adobe CJK collections and predefined CJK CMaps independently (#3673); per-glyph validation and embedded-font extraction remain separate.
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 for one page. Marking a figure decorative rewrites only that stream (#1324). An XFA radio item whose value contains a space is written back unchanged (#1319). Done since: #2569: Growable multiline XFA text reflows after edits, with undo/redo, save/reopen and print coverage; oversized fields still become read-only page text. Structured-data synchronization warnings retain accepted whitespace without relayout from stale data. Done since: #2572: Every repeated page-master button resolves its own field to the shared XFA click script, including print buttons. Done since: #2570: Dynamic XFA rich-text fields carry exData/maxLength into AcroForm MaxLen for reported and enforced Unicode character limits, retained after save/reopen. Done since: #2589: Active XFA skips PDF-container fallback scripts while retaining requested messages and saved scripts; ordinary/static/unlaid-out documents and explicit console/field calls keep their existing behavior. Missing: writing proposed tags and parent tree, field autotag, visual Tags/Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA
Reading colours: pending page backgrounds follow the configured colour (#2059). | | 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. Character encoding accepts Identity-H/V with Adobe CJK collections and predefined CJK CMaps independently (#3673); per-glyph validation and embedded-font extraction remain separate.
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
Done since: CLI info, text and render use dynamic XFA layout pages, with synthetic count/text/pixel and copy-permission regressions (#1645).
Reading colours: pending page backgrounds follow the configured colour (#2059). | | 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 and Arabic (logical catalog; explicit right-to-left labels and mirrored stock chrome; end-to-end RTL workflows unqualified); the 2,674-entry Brazilian Portuguese catalog covers the current key union; 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. Norwegian Bokmål adds a catalog and date names (terminology reviewed by a native speaker). 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. Candidate #2383 clamps GPU requests to supported limits while retaining the adapter texture extent; synthetic V3D/downlevel and Windows GPU-required tests pass, but Raspberry Pi startup is unverified. Missing: end-to-end RTL workflow qualification, a Chinese web UI face, Hindi/Indonesian/Korean, file-backed input and global memory budgets, performance budgets, keyboard-only operation Linux/Unix UI fallback fonts also use Fontconfig directories, including Nix store paths, with bounded discovery and the existing candidate allowlist; relative includes and digit-prefixed config filtering have isolated regressions. Missing: end-to-end RTL workflow qualification, a Chinese web UI face, Hindi/Indonesian/Korean, file-backed input and global memory budgets, performance budgets, keyboard-only operation XDG desktops use the configuration base for preferences with non-overwriting migration and original-data preservation (#1395). Traditional Chinese accessibility statuses, rule semantics and Taiwan form/table terms have catalog regressions. Long main-menu lists scroll in short windows (#3373). Missing: end-to-end RTL workflow qualification, a Chinese web UI face, Hindi/Indonesian/Korean, file-backed input and global memory budgets, performance budgets, keyboard-only operation. Candidate #3558: bounded-worker macOS per-file PDFCraft association recovery after desktop open/save/sign; metadata-only watcher handling and upstream #3042 shared-writer preservation; recovery parser limits do not narrow save limits. Native workflow qualified on macOS 27.0.1; other versions and physical SMB/FUSE volumes remain unqualified. Estimates unchanged. Missing: end-to-end RTL workflow qualification, a Chinese web UI face, 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 | Local customization: adjustable, floating or side-docked hotbar with optional full-side strip and persistent tool picker; freehand highlighting is grouped with Highlight. Estimates unchanged. 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 and Arabic (logical catalog; explicit right-to-left labels and mirrored stock chrome; end-to-end RTL workflows unqualified); the 2,674-entry Brazilian Portuguese catalog covers the current key union; 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. Norwegian Bokmål adds a catalog and date names (terminology reviewed by a native speaker). 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. Candidate #2383 clamps GPU requests to supported limits while retaining the adapter texture extent; synthetic V3D/downlevel and Windows GPU-required tests pass, but Raspberry Pi startup is unverified. Missing: end-to-end RTL workflow qualification, a Chinese web UI face, Hindi/Indonesian/Korean, file-backed input and global memory budgets, performance budgets, keyboard-only operation Linux/Unix UI fallback fonts also use Fontconfig directories, including Nix store paths, with bounded discovery and the existing candidate allowlist; relative includes and digit-prefixed config filtering have isolated regressions. Missing: end-to-end RTL workflow qualification, a Chinese web UI face, Hindi/Indonesian/Korean, file-backed input and global memory budgets, performance budgets, keyboard-only operation XDG desktops use the configuration base for preferences with non-overwriting migration and original-data preservation (#1395). Traditional Chinese accessibility statuses, rule semantics and Taiwan form/table terms have catalog regressions. Long main-menu lists scroll in short windows (#3373). Missing: end-to-end RTL workflow qualification, a Chinese web UI face, Hindi/Indonesian/Korean, file-backed input and global memory budgets, performance budgets, keyboard-only operation. Candidate #3558: bounded-worker macOS per-file PDFCraft association recovery after desktop open/save/sign; metadata-only watcher handling and upstream #3042 shared-writer preservation; recovery parser limits do not narrow save limits. Native workflow qualified on macOS 27.0.1; other versions and physical SMB/FUSE volumes remain unqualified. Estimates unchanged. Missing: end-to-end RTL workflow qualification, a Chinese web UI face, 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 | | | **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) | @@ -242,18 +288,40 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st ## Revision history +**2026-10-26 · patch:** M2/PR #4172: reconcile current main and preserve upstream Japanese serif/weight selection alongside the installed CJK viewer and preview fallback. Estimates unchanged. + **2026-10-25 · minor:** M14 localization current state: Arabic catalogs stay logical, Arabic/Kurdish labels take an explicit RTL direction, and stock chrome mirrors; native/browser RTL input, IME and complex workflows remain unqualified. Native Simplified Chinese uses LXGW WenKai Lite; the web build has no Hans face. Estimates unchanged. **2026-10-23 · minor:** M11: image downsampling uses physical resolution after page-local `/UserUnit` scaling, including shared nested forms. Synthetic measurement and optimization regressions cover the change. Estimates unchanged. +**2026-10-23 · minor:** M2/PR #4172: display-only CJK fallback covers Combine thumbnails and accessibility figure previews. Windows family matching includes FangSong and subset-prefixed Founder names. Export, print, OCR and automation policy and readiness estimates are unchanged. + **2026-10-20 · minor:** M2/#3601: substitute-font spacing aliases preserve blank ink, original `/W` and `/DW` advances, and Unicode extraction. Synthetic renderer regressions cover the reported Japanese CID fixture. Estimates unchanged. **2026-10-15 · minor:** M14/#2383 candidate: GPU device requests clamp unsupported limits while retaining the adapter texture extent. Synthetic V3D/downlevel regressions and Windows GPU-required tests pass; Raspberry Pi hardware remains unverified. Estimates unchanged. | Date | Change | Summary | |---|---|---| +| 2026-11-03 | patch | M5 #5850 review: indirect typewriter intent and composer edits preserve outer whitespace; movement regression commits by outside click; estimates unchanged. | +| 2026-11-03 | patch | M5 #5850: Enter adds Fill & Sign line breaks; outside clicks commit, Escape cancels, and saved multiline appearances retain their text and height; estimates unchanged. | +| 2026-11-02 | patch | M10 #5743: macOS print jobs carry the job name and owner some vendors' filters need; not yet checked on a physical printer | +| 2026-11-01 | patch | M7 #5657: header/footer, watermark and background operations commit all selected pages together; later-page errors preserve marks, content, shared/inherited resources and pending edits; estimates unchanged. | +| 2026-10-31 | patch | #1577: reconcile optional page shadows with current viewer/organizer and settings modules; CI validation pending. | +| 2026-10-31 | patch | M3 #1354: reconcile the launch override with split app/file modules and the structured open result; preserve headless help/version exits. CI validation pending. | +| 2026-10-31 | patch | M2, PR #4172: eight GBK font/alias cases compare exact pixels with independent installed-face and explicit-Unicode references; wrong-glyph and wrong-family negative controls fail. Estimates unchanged. | +| 2026-10-31 | patch | M2, PR #4172: integrate main fa5f9f8c, preserve both raster test groups and restore explicit display_config imports; display-only CJK policy and estimates unchanged. | +| 2026-10-28 | patch | M2, PR #4172: integrate main 5af10276 and retain both roadmap histories; viewer-only CJK and print/output font policies and estimates unchanged. | +| 2026-10-24 | minor | M11: the PDF Optimizer and Reduce File Size store Group 3 fax images as Group 4 when smaller (`Document::fax_params`), and pack eight-bit `DeviceRGB` of only black and white (as grey) and indexed images of 2, 4 or 8 bits that use only palette entries 0 and 1 (keeping the palette) to one bit, so Monochrome Images compresses and downsamples them. Estimates unchanged. | +| 2026-10-24 | minor | M11: monochrome downsampling: the PDF Optimizer and Reduce File Size average black-and-white images (1-bit, CCITT fax, eight-bit black-and-white grey) above 450 ppi down to 300 ppi, by area with ties black, as Monochrome Images ▸ Downsample to and `doc_optimize` `mono: { downsample, ppi, above_ppi }`; CCITT decoding moves into `pdfcraft-filters` (`decode_ccitt`) behind `Document::decoded_image`. Estimates unchanged. | +| 2026-10-24 | minor | M11: Create PDF stores pictures of only black and white at one bit (Group 4 or Flate, whichever is smaller) instead of eight-bit grey; the optimizer packs eight-bit black-and-white grey images to one bit before Monochrome Images compresses them, unless the grey settings make them smaller, as they can a dithered scan, soft mask counted; Export ▸ Images decodes CCITT fax images. Estimates unchanged. | +| 2026-10-26 | minor | M11: lossless JBIG2 symbol coding (`pdfcraft_filters::encode_jbig2`): each distinct mark stored once in a symbol dictionary and its exact copies placed by a text region, larger and lone marks in a generic region, used when smaller than the generic coding; on six pages of rendered 300 dpi text 28% of the generic region's size and 17% of Group 4's. Scans gain little until refinement coding; `/JBIG2Globals` and lossy coding not yet. Estimates unchanged. | +| 2026-10-23 | minor | M11: lossless JBIG2 for black-and-white images (`pdfcraft_filters::encode_jbig2_generic`, a generic region coded with the MQ coder, written from the JBIG2 draft standard), as Monochrome Images ▸ JBIG2 (lossless) and `doc_optimize` `mono: jbig2`; about a third smaller than Group 4 on 300 dpi text. Export ▸ Images reads JBIG2 images (`hayro-jbig2`, with `/JBIG2Globals`). Symbol coding not yet. Estimates unchanged. | +| 2026-10-23 | minor | M11: black-and-white images (1-bit, and image masks) are stored losslessly as CCITT Group 4 when that is smaller: PDF Optimizer ▸ Images ▸ Monochrome Images (on by default, so Reduce File Size too), `doc_optimize` `mono`; `pdfcraft_filters::encode_g4`. JBIG2 and monochrome downsampling not yet. Estimates unchanged. | +| 2026-10-23 | minor | M6: calculation checks: a formula reading a field the form doesn't have, and a script building the names it reads, are flagged by `form_calculation_order` and on the Calculation order page (a red mark, a card naming the broken formulas, a note for built names); `form_calculation_order` gives each formula and `form_fields` a custom Calculate script. Estimates unchanged. | +| 2026-10-29 | minor | M7 #2517: widening a paragraph rejoins a wrap with the spaces that wrap consumed, including a typed run and none for a Chinese or Japanese word split, after an incremental save and a full rewrite. Estimates unchanged. | +| 2026-10-27 | minor | M10: OCR stages SHA-256-verified ONNX models after the upstream RTEN URL brownout; discovery prefers a complete ONNX pair per directory, falls back to a complete RTEN pair, and never mixes formats or directories; language/web limits and estimates unchanged. | | 2026-10-24 | minor | M3, #3970 follow-up: the button release given back after a window move or resize goes before the pointer leaving, so hover doesn't stick. Estimates unchanged. | | 2026-10-23 | minor | M8, #3205 follow-up: phone-pattern redaction includes the +39 country code on labelled Italian geographic numbers with a slash; dates, protocol identifiers and whole-run guards remain. Estimates unchanged. | | 2026-10-23 | minor | M10: Word table cells use the grid columns they span, including cells after a merged cell; synthetic export regression and CLI reproduction. Estimates unchanged. | @@ -263,6 +331,9 @@ Estimates unchanged. | 2026-10-21 | minor | M3: on GNOME/Wayland, a second resize by an edge or move by the tab strip works without a click in between (the compositor kept the button's release from egui). Estimates unchanged. | | 2026-10-22 | minor | M4, #3916 partial: edits and Undo/Redo cancel Organize page drags. Pointer-release and save/reopen regressions check page order. Estimates unchanged. | | 2026-10-22 | minor | M14 #3558: rebased macOS association recovery; reuse #3042 shared metadata preservation instead of a duplicate save wrapper. Estimates unchanged. | +| 2026-10-22 | minor | M7 #2517: a partial rewrite of two paragraphs that share one text object closes the marker before the neighbour, so a later edit does not delete it. Estimates unchanged. | +| 2026-10-18 | minor | M7 #2517: a partial rewrite of the last paragraph closes its marker after the last `ET`, and does not close a following marked region early. Estimates unchanged. | +| 2026-10-15 | minor | M7 #2517: a blank line between paragraphs stays a paragraph break, and growing the first paragraph does not absorb the next one. Estimates unchanged. | | 2026-10-20 | minor | M4: initial-view window and interface options resolve indirect boolean ViewerPreferences values; synthetic true/false and save/reopen regressions retain defaults for malformed entries. Estimates unchanged. | | 2026-10-21 | minor | M3, #3808: the Pages inspector has an independent 50–150% thumbnail size slider and buttons; selection and current-page following are retained, with headless ui.set coverage. Estimates unchanged. | | 2026-10-20 | minor | M11 #3507: image merging preserves comments and state across content streams. Estimates unchanged. | @@ -355,3 +426,7 @@ Estimates unchanged. | 2026-10-12 | minor | M8: image redaction removes alternate images and unsupported masks only on affected images. Estimates unchanged. | | 2026-10-10 | major | Created from ROADMAP.md §Milestones, §Critical path, §Risks and §Where we're lacking. Merged the duplicated M1, M2, M3 and M14 rows, re-measured Done and Remaining, added Current focus and the ranked upcoming milestones | | 2026-10-18 | minor | #3227: M11: integrate upstream b0e5c6eb; retain partial target-version compatibility claims and effective-version reporting, correcting the borrowed version parser lifetime. Scope and estimates unchanged. | + +## Pages sidebar keyboard refresh (2026-10-30) + +PR #2133 retains document-local Pages sidebar focus for Ctrl/Cmd+A and Escape after the UI module split. The selection does not navigate. The focused regression is in `crates/ui-egui/tests/it/view.rs`; current-head hosted validation is pending. Feature counts and estimates are unchanged. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 6c7920e33c..d03c27c95c 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,5 +1,9 @@ # PdfCraft parity with Adobe Acrobat Pro +> Integration update 2026-10-30: lossless JBIG2 generic and exact-copy symbol coding are included; no lossy substitution. Combined tests passed at bdf5620; the subsequent review fixes require fresh CI. + +> **Last reviewed:** 2026-10-25 · **Last updated:** 2026-10-26 · **Change:** minor (K Optimize and standards no longer lists monochrome downsampling as missing; scores and estimates unchanged) +> **Last reviewed:** 2026-10-25 · **Last updated:** 2026-10-26 · **Change:** minor (`optimize.mono-compression` partial (CCITT Group 4): K Optimize and standards 15 shipped and 11 partial of 58, 35.3%; breadth 57.0% unweighted (423 + 75 of 808), mainstream 63.3% (292 + 45 of 497); estimates unchanged) > **Last reviewed:** 2026-10-25 · **Last updated:** 2026-10-25 · **Change:** minor (localization current state: Arabic logical catalog, explicit RTL direction and mirrored stock chrome; native/browser RTL workflows unqualified. Native release Hans face via LXGW WenKai Lite. Scores and estimates unchanged) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (localization ≈59% → ≈68%; weighted readiness 49.25 → 49.7 ≈ 50%) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -20,9 +24,9 @@ Approved Trust List) are out of scope: 23 such features are tracked as `na` in | | Value | Kind | |---|---|---| | **Feature breadth**, tier-weighted | **≈ 67%** | measured (checklist; weights below) | -| Feature breadth, unweighted | 56.9% (423 shipped + 74 partial of 808) | measured (`cargo xtask parity` arithmetic) | +| Feature breadth, unweighted | 57.0% (423 shipped + 75 partial of 808) | measured (`cargo xtask parity` arithmetic) | | Must-have (P0) features | 94.2% (226 + 23 partial of 252) | measured | -| Feature breadth, mainstream practitioner | ≈ 70% tier-weighted (63.2% unweighted: 292 + 44 partial of 497; P0 95.3%) | measured ([split below](#mainstream-practitioner-vs-full-acrobat-pro)) | +| Feature breadth, mainstream practitioner | ≈ 70% tier-weighted (63.3% unweighted: 292 + 45 partial of 497; P0 95.3%) | measured ([split below](#mainstream-practitioner-vs-full-acrobat-pro)) | | **Ready for real work**, full Acrobat Pro | **≈ 50%** (range 45–53%) | estimated (weighted sum over dimensions, [below](#by-dimension)) | | **Ready for real work**, mainstream practitioner | **≈ 55%** (range 50–60%) | estimated ([split below](#mainstream-practitioner-vs-full-acrobat-pro)) | | **Ready for real work**, essentials user | **≈ 66%** (range 60–70%) | estimated ([below](#essentials-user)) | @@ -94,8 +98,8 @@ platforms 5%, hardware 4%, ecosystem 1%, AI 1%: History: the first version of this file subtracted 9 points from the area figure for cross-cutting shortfalls (40%); the second look found that a double count (Windows printing, encrypted-output interop and Chinese glyphs were already scored in their areas) and used a 2-point deduction (47%); -this version replaces the deduction with the standard's weighted sum over dimensions (49%). Method -aligned with the standard, no new evidence. Multiplicative discounts are used only for the +this version replaces the deduction with the weighted sum over dimensions (49%), with no new +evidence. Multiplicative discounts are used only for the mainstream and essentials numbers. ## Mainstream practitioner vs full Acrobat Pro @@ -105,9 +109,9 @@ Splitting it (measured from `parity/acrobat-features.toml`): | Slice | Features | Shipped | Partial | Unweighted | Tier-weighted | P0 | |---|---|---|---|---|---|---| -| Mainstream: view, comment (without measuring), edit, organize, forms (without XFA and JavaScript tooling), protect and redact, OCR, create and export, compress (Reduce File Size, Optimizer), print (without print production) | 497 | 292 | 44 | 63.2% | ≈ 70% | 95.3% (193) | +| Mainstream: view, comment (without measuring), edit, organize, forms (without XFA and JavaScript tooling), protect and redact, OCR, create and export, compress (Reduce File Size, Optimizer), print (without print production) | 497 | 292 | 45 | 63.3% | ≈ 70% | 95.3% (193) | | Specialist: core internals, digital-signature PKI, accessibility remediation, automation and preferences, Preflight and PDF/A/X/UA, print production, measuring, XFA and JavaScript tooling | 311 | 131 | 30 | 46.9% | ≈ 63% | 90.7% (59) | -| All | 808 | 423 | 74 | 56.9% | ≈ 67% | 94.2% (252) | +| All | 808 | 423 | 75 | 57.0% | ≈ 67% | 94.2% (252) | So the checklist is **not** dominated by niche surface (38% of rows), and the P0 tier, which is mostly mainstream, is at 94–95% in both slices. What holds the mainstream number down is P1 depth @@ -208,11 +212,11 @@ full parity in that area. | H Digital signatures | 45 | 23 | 10 | 62.2% | 45% | 40–70 | PAdES B-B signing and validation (PKCS #12, macOS Keychain, Windows store with PIN prompts), certification, encrypted documents, certificate usage checks; B-T, LTV, OCSP/CRL with caller-supplied evidence. Missing: PKCS #11 and smart cards outside Windows, timestamp-server preferences, online revocation fetching, FieldMDP and field locking, OS trust store, EU Trusted Lists, compare signed version | | I Scan and OCR | 26 | 5 | 2 | 23.1% | 15% | 40–75 | Searchable image (ocrs, Latin script without accents) on pages, ranges and many files. Missing: every other language (#744 Dutch; accents, CJK, Cyrillic, Arabic), editable-text output, deskew, rotation, despeckle, camera images beyond straightening (found or placed corners): orientation, clean-up, MRC compression, scanners | | J Create and export | 34 | 12 | 3 | 39.7% | 25% | 55–100 | Create from images, multipage TIFF, text, clipboard, blank, many files at once; export PNG/JPEG/TIFF, text, RTF; Word and HTML partial. Missing: Office/HTML/web/PostScript to PDF, Excel and PowerPoint export, Word layout fidelity (#773), SVG/XML/EPS/PS/JPEG 2000 export, accessible text | -| K Optimize and standards | 58 | 15 | 10 | 34.5% | 20% | 65–115 | Reduce File Size and the Optimizer (images, discards, clean-up, space audit); PDF/A-2b/3b verify and convert (partial). Missing: Preflight (profiles, fixups, reports), PDF/A-1/4 and a/u levels, PDF/X, PDF/UA, PDF/E, PDF/VT, transparency flattening, font unembedding, monochrome JBIG2/CCITT | +| K Optimize and standards | 58 | 15 | 11 | 35.3% | 20% | 65–115 | Reduce File Size and the Optimizer (images, CCITT and lossless JBIG2 monochrome compression, discards, clean-up, space audit); PDF/A-2b/3b verify and convert (partial). Missing: Preflight (profiles, fixups, reports), PDF/A-1/4 and a/u levels, PDF/X, PDF/UA, PDF/E, PDF/VT, transparency flattening, font unembedding, lossy/refinement JBIG2 | | L Print | 39 | 15 | 1 | 39.7% | 35% | 30–55 | Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, print-ready PDF, CUPS on macOS/Linux, the Windows spooler (#756) and the browser's print dialog on the web. Missing: print as image, PostScript, all print-production tools (output preview, separations, ink manager, printer marks, colour conversion) | | M Accessibility | 61 | 38 | 1 | 63.1% | 30% | 40–70 | Accessibility Checker (all 32 rules, report, fixes), alternate text, title and language. Missing: autotag, Tags/Order/Content panels, Reading Order tool, artifact marking, table editor, Read Out Loud, full keyboard operation of the app, screen-reader audit | | N Automation, preferences, misc | 75 | 20 | 5 | 30.0% | 35% | 25–45 | CLI, MCP, UI control channel, 141 automation tools, Action Wizard (run, create), compare (text, visual, report, side by side, Text/Visual filter). Missing: most Preferences pages, Acrobat's finer compare filters, Action Wizard management, custom commands, AI providers, rich media/3D, hosted web app | -| **All** | **808** | **423** | **74** | **56.9%** | **≈ 49%** | **610–1,100** | Plus 175–325 h of cross-cutting work (dimension table) | +| **All** | **808** | **423** | **75** | **57.0%** | **≈ 49%** | **610–1,100** | Plus 175–325 h of cross-cutting work (dimension table) | Shipped features rest on thin evidence: 141 of 423 cite exactly one test, and only a handful cite an external oracle (`pdftotext`, `pdfsig`, OpenSSL, `qpdf --check`, a corpus). @@ -294,13 +298,15 @@ licensed OCR or AI model. | Date | Change | Summary | |---|---|---| +| 2026-10-26 | minor | K Optimize and standards no longer lists monochrome downsampling as missing (black-and-white images above 450 ppi are averaged down to 300). Scores and estimates unchanged. | +| 2026-10-26 | minor | `optimize.mono-compression` became partial (CCITT Group 4), counted on the table's own measurement: K Optimize and standards 15 shipped and 11 partial of 58 (35.3%), no longer listing CCITT as missing; breadth 57.0% unweighted (423 + 75 of 808), mainstream 63.3% (292 + 45 of 497). Estimates unchanged. | | 2026-10-25 | minor | Localization current state: Arabic catalogs stay logical, Arabic/Kurdish labels take an explicit RTL direction, and stock chrome mirrors; native/browser RTL input, IME and complex workflows remain unqualified. Native Simplified Chinese uses LXGW WenKai Lite; the web build has no Hans face. Scores and estimates unchanged. | | 2026-10-20 | minor | M12 #3673: the Character encoding rule accepts Identity-H/V fonts with Adobe CJK collections, or a predefined non-Identity CMap, while rejecting unknown names and foreign registries. Synthetic checker and read-only automation regressions passed after failing before the fix; per-glyph mapping validation and embedded-font extraction remain separate. Breadth, readiness and hours 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. | | 2026-10-11 | minor | Web printing shipped (gap 4): Hardware, Platforms and L Print text updated. Text only: scores, readiness and hours not re-judged | | 2026-10-11 | minor | Contributor fork: document-font selection and subset completion, CFF, style ranges, contextual/bidi save/reopen and all-page Chinese keyboard acceptance; curve/hole hit testing (shared clips stay fixed, as on main). Scope remains bounded; percentages unchanged | | 2026-10-11 | minor | Synced with closed issues: encrypted output opens in Acrobat (#774, #836), Windows prints (#756, #847), column select (#740), 1:1 touchpad scrolling (#759), same-page multi-object move (#844), #825 and the #799–#821 fix batch. Text only: scores, readiness and hours not re-judged | -| 2026-10-10 | minor | Readiness table: hours to ≈ 95% per audience (full 780–1,400, mainstream 500–900, essentials 180–330). Full number restated as the standard's additive weighted sum over dimensions with written weights: 47% → 49%. Method aligned with the standard, no new evidence | +| 2026-10-10 | minor | Readiness table: hours to ≈ 95% per audience (full 780–1,400, mainstream 500–900, essentials 180–330). Full number restated as the additive weighted sum over dimensions with written weights: 47% → 49%. No new evidence | | 2026-10-10 | minor | Added the essentials-user score (≈ 66%): weighted depth of 13 everyday features, discounted for launch, discoverability and opening others' files | | 2026-10-10 | minor | Consistency check across apps: re-judged the convert-and-share gate row with the shared rule (blocking only when the workflow can't be completed at all; lossy exchange and missing sub-cases are beta items). Encrypted output (#774) and Office→PDF are sub-cases, Word export is lossy not absent → partial, not blocking; stage pre-alpha → **alpha**. Numbers unchanged | | 2026-10-10 | minor | Second look requested by the owner after practitioners praised PdfCraft: user sentiment gathered (stars, downloads, 23 praising issues, switching reports, issue themes); checklist split into mainstream (497) and specialist (311) features; the 9-point cross-cutting deduction removed as a double count (Windows printing, interop and glyphs were already scored in their areas). Ready for real work 40% → 47%; mainstream practitioner ≈ 55% added. Core-workflow alpha gate applied: convert and share fails → stage alpha → **pre-alpha**, ≈ 40–70 h from alpha | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 703f1e9d8b..f950e36a94 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -26,7 +26,7 @@ No dump of Acrobat's menu tree has been diffed against the registry yet (gap 24) |---|---|---|---| | Home view (recent files, recommended tools) | yes | yes | shipped | | All tools pane, tool sets | yes | yes, with milestone badges | shipped | -| Quick-action toolbar, customisable | yes | fixed | partial (`view.customize-quick-tools` planned) | +| Quick-action toolbar, customisable | yes | customizable floating/docked hotbar, adjustable size, persistent multi-select picker | partial (`view.customize-quick-tools`; no shortcut reordering) | | Command search | "Search tools" | command palette (⌘K), matches translated and English labels | shipped | | Document tabs | yes | yes, with unsaved marker | shipped | | Multiple windows, drag a tab out | yes | no | planned | @@ -47,6 +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 | +| Moved comments drawn where they go while dragged | yes | yes: the comment's own pixels follow the pointer, the page shows it where it was until release; stamps and Fill & Sign marks also hide the original | 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 | diff --git a/licenses/CMap-LICENSE.txt b/licenses/CMap-LICENSE.txt new file mode 100644 index 0000000000..2552cc990c --- /dev/null +++ b/licenses/CMap-LICENSE.txt @@ -0,0 +1,28 @@ +Copyright 1990-2023 Adobe. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + +Redistributions of source code must retain the above copyright notice, +this list of conditions and the following disclaimer. + +Redistributions in binary form must reproduce the above copyright +notice, this list of conditions and the following disclaimer in the +documentation and/or other materials provided with the distribution. + +Neither the name of Adobe nor the names of its contributors may be +used to endorse or promote products derived from this software without +specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/licenses/egui-Hack-Regular.txt b/licenses/egui-Hack-Regular.txt new file mode 100644 index 0000000000..b0ec74488a --- /dev/null +++ b/licenses/egui-Hack-Regular.txt @@ -0,0 +1,31 @@ +The work in the Hack project is Copyright 2018 Source Foundry Authors and licensed under the MIT License + +The work in the DejaVu project was committed to the public domain. + +Bitstream Vera Sans Mono Copyright 2003 Bitstream Inc. and licensed under the Bitstream Vera License with Reserved Font Names "Bitstream" and "Vera" +MIT License + +Copyright (c) 2018 Source Foundry 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. +BITSTREAM VERA LICENSE + +Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is a trademark of Bitstream, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy of the fonts accompanying this license ("Fonts") and associated documentation files (the "Font Software"), to reproduce and distribute the Font Software, including without limitation the rights to use, copy, merge, publish, distribute, and/or sell copies of the Font Software, and to permit persons to whom the Font Software is furnished to do so, subject to the following conditions: + +The above copyright and trademark notices and this permission notice shall be included in all copies of one or more of the Font Software typefaces. + +The Font Software may be modified, altered, or added to, and in particular the designs of glyphs or characters in the Fonts may be modified and additional glyphs or characters may be added to the Fonts, only if the fonts are renamed to names not containing either the words "Bitstream" or the word "Vera". + +This License becomes null and void to the extent applicable to Fonts or Font Software that has been modified and is distributed under the "Bitstream Vera" names. + +The Font Software may be sold as part of a larger software package but no copy of one or more of the Font Software typefaces may be sold by itself. + +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE. + +Except as contained in this notice, the names of Gnome, the Gnome Foundation, and Bitstream Inc., shall not be used in advertising or otherwise to promote the sale, use or other dealings in this Font Software without prior written authorization from the Gnome Foundation or Bitstream Inc., respectively. For further information, contact: fonts at gnome dot org. diff --git a/licenses/egui-OFL.txt b/licenses/egui-OFL.txt new file mode 100644 index 0000000000..d952d62c06 --- /dev/null +++ b/licenses/egui-OFL.txt @@ -0,0 +1,92 @@ +This Font Software is licensed under the SIL Open Font License, +Version 1.1. + +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font +creation efforts of academic and linguistic communities, and to +provide a free and open framework in which fonts may be shared and +improved in partnership with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply to +any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software +components as distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, +deleting, or substituting -- in part or in whole -- any of the +components of the Original Version, by changing formats or by porting +the Font Software to a new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, +modify, redistribute, and sell modified and unmodified copies of the +Font Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, in +Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the +corresponding Copyright Holder. This restriction only applies to the +primary font name as presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created using +the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/licenses/egui-UFL.txt b/licenses/egui-UFL.txt new file mode 100644 index 0000000000..ae78a8f94e --- /dev/null +++ b/licenses/egui-UFL.txt @@ -0,0 +1,96 @@ +------------------------------- +UBUNTU FONT LICENCE Version 1.0 +------------------------------- + +PREAMBLE +This licence allows the licensed fonts to be used, studied, modified and +redistributed freely. The fonts, including any derivative works, can be +bundled, embedded, and redistributed provided the terms of this licence +are met. The fonts and derivatives, however, cannot be released under +any other licence. The requirement for fonts to remain under this +licence does not require any document created using the fonts or their +derivatives to be published under this licence, as long as the primary +purpose of the document is not to be a vehicle for the distribution of +the fonts. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this licence and clearly marked as such. This may +include source files, build scripts and documentation. + +"Original Version" refers to the collection of Font Software components +as received under this licence. + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to +a new environment. + +"Copyright Holder(s)" refers to all individuals and companies who have a +copyright ownership of the Font Software. + +"Substantially Changed" refers to Modified Versions which can be easily +identified as dissimilar to the Font Software by users of the Font +Software comparing the Original Version with the Modified Version. + +To "Propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification and with or without charging +a redistribution fee), making available to the public, and in some +countries other activities as well. + +PERMISSION & CONDITIONS +This licence does not grant any rights under trademark law and all such +rights are reserved. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of the Font Software, to propagate the Font Software, subject to +the below conditions: + +1) Each copy of the Font Software must contain the above copyright +notice and this licence. These can be included either as stand-alone +text files, human-readable headers or in the appropriate machine- +readable metadata fields within text or binary files as long as those +fields can be easily viewed by the user. + +2) The font name complies with the following: +(a) The Original Version must retain its name, unmodified. +(b) Modified Versions which are Substantially Changed must be renamed to +avoid use of the name of the Original Version or similar names entirely. +(c) Modified Versions which are not Substantially Changed must be +renamed to both (i) retain the name of the Original Version and (ii) add +additional naming elements to distinguish the Modified Version from the +Original Version. The name of such Modified Versions must be the name of +the Original Version, with "derivative X" where X represents the name of +the new work, appended to that name. + +3) The name(s) of the Copyright Holder(s) and any contributor to the +Font Software shall not be used to promote, endorse or advertise any +Modified Version, except (i) as required by this licence, (ii) to +acknowledge the contribution(s) of the Copyright Holder(s) or (iii) with +their explicit written permission. + +4) The Font Software, modified or unmodified, in part or in whole, must +be distributed entirely under this licence, and must not be distributed +under any other licence. The requirement for fonts to remain under this +licence does not affect any document created using the Font Software, +except any version of the Font Software extracted from a document +created using the Font Software may only be distributed under this +licence. + +TERMINATION +This licence becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF +COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER +DEALINGS IN THE FONT SOFTWARE. diff --git a/licenses/egui-emoji-icon-font-mit-license.txt b/licenses/egui-emoji-icon-font-mit-license.txt new file mode 100644 index 0000000000..99016e120b --- /dev/null +++ b/licenses/egui-emoji-icon-font-mit-license.txt @@ -0,0 +1,9 @@ +MIT License + +Copyright (c) 2014 John Slegers + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/packaging/freebsd/package.sh b/packaging/freebsd/package.sh index e22bca4eae..0533be9010 100755 --- a/packaging/freebsd/package.sh +++ b/packaging/freebsd/package.sh @@ -31,6 +31,10 @@ release_arch() { } # The release workflow cross-compiles aarch64 in an x86_64 VM with CARGO_BUILD_TARGET set to # aarch64-unknown-freebsd (craft-common-actions#39): name the archive after the target. +# host-tuple means the build host's triple, as rustc names it. +if [[ "${CARGO_BUILD_TARGET:-}" == host-tuple ]]; then + CARGO_BUILD_TARGET="$(rustc -vV | sed -n 's/^host: //p')" +fi case "${CARGO_BUILD_TARGET:-}" in "") ARCH="$(release_arch "$(uname -m)")" ;; aarch64-unknown-freebsd | x86_64-unknown-freebsd) ARCH="$(release_arch "${CARGO_BUILD_TARGET%%-*}")" ;; @@ -47,7 +51,7 @@ BASENAME="pdfcraft-$VERSION-freebsd-$ARCH" echo "==> PdfCraft $VERSION for FreeBSD $ARCH" if [[ "$SKIP_BUILD" = 0 ]]; then - (cd "$ROOT" && cargo build --release --locked -p pdfcraft -p pdfcraft-cli) + (cd "$ROOT" && cargo build --release --locked ${CARGO_BUILD_TARGET:+--target "$CARGO_BUILD_TARGET"} -p pdfcraft -p pdfcraft-cli) fi # Cargo puts a cross build under target//release. BIN="$CARGO_TARGET_DIR/${CARGO_BUILD_TARGET:+$CARGO_BUILD_TARGET/}release" @@ -70,7 +74,7 @@ copy_docs "$STAGE/share/doc/pdfcraft" # which has to run here: build it for this machine, not for CARGO_BUILD_TARGET. (unset CARGO_BUILD_TARGET; stage_models "$STAGE/share/pdfcraft/models") -if [ "$ARCH" = "$HOST_ARCH" ]; then +if [ -z "${CARGO_BUILD_TARGET:-}" ] && [ "$ARCH" = "$HOST_ARCH" ]; then "$STAGE/bin/pdfcraft" --version else # They cannot run here, so check what they are instead: executables, not libraries or objects. @@ -82,7 +86,7 @@ else *) echo "error: $b is not a FreeBSD $ARCH executable: $(file -b "$b")" >&2; exit 1 ;; esac done - echo "skipping --version: an $ARCH binary does not run on this $HOST_ARCH machine" + echo "skipping --version: a CARGO_BUILD_TARGET=$CARGO_BUILD_TARGET build does not run on this $HOST_ARCH machine" fi mkdir -p "$DIST" tar -C "$WORK" -czf "$DIST/$BASENAME.tar.gz" "$BASENAME" diff --git a/packaging/linux/flatpak-bundle.sh b/packaging/linux/flatpak-bundle.sh index 26e09cd1c8..0534b3194e 100755 --- a/packaging/linux/flatpak-bundle.sh +++ b/packaging/linux/flatpak-bundle.sh @@ -20,10 +20,12 @@ APP_ID=ai.storyteller.pdfcraft FLATHUB=https://dl.flathub.org/repo/flathub.flatpakrepo TEST=1 +OCI=0 TARBALL="" while [[ $# -gt 0 ]]; do case "$1" in --no-test) TEST=0; shift ;; + --oci) OCI=1; shift ;; -h | --help) sed -n '2,14p' "$0"; exit 0 ;; -*) echo "unknown argument: $1" >&2; exit 2 ;; *) TARBALL="$1"; shift ;; @@ -62,6 +64,13 @@ OUT="$DIST/$BASENAME.flatpak" flatpak build-bundle --runtime-repo="$FLATHUB" "$WORK/repo" "$OUT" "$APP_ID" stable echo "wrote $OUT" +if [[ "$OCI" = 1 ]]; then + OUT_OCI="$DIST/$BASENAME-oci" + rm -rf "$OUT_OCI" + flatpak build-bundle --oci "$WORK/repo" "$OUT_OCI" "$APP_ID" stable + echo "wrote OCI layout to $OUT_OCI" +fi + if [[ "$TEST" = 1 ]]; then flatpak install --user -y --noninteractive --reinstall "$OUT" flatpak info --user "$APP_ID" diff --git a/packaging/linux/flatpak-oci.sh b/packaging/linux/flatpak-oci.sh new file mode 100755 index 0000000000..0572ef1a3a --- /dev/null +++ b/packaging/linux/flatpak-oci.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# Build PdfCraft as an OCI container layout and push it to GitHub Container Registry (GHCR) +# for consumption by Flatpak OCI clients (flatpak-oci-specs). +# +# Usage: +# packaging/linux/flatpak-oci.sh [--push] [TARBALL] +# +# Environment variables: +# REGISTRY Target registry image (default: ghcr.io/storytold/pdfcraft) +# TAG Target image tag (default: $VERSION-$ARCH, also tags stable and latest) +# GITHUB_TOKEN Token for GHCR authentication when --push is used +set -euo pipefail +# shellcheck source=../env.sh +. "$(dirname "${BASH_SOURCE[0]}")/../env.sh" +HERE="$ROOT/packaging/linux" + +PUSH=0 +TARBALL="" +while [[ $# -gt 0 ]]; do + case "$1" in + --push) PUSH=1; shift ;; + -h | --help) sed -n '2,12p' "$0"; exit 0 ;; + -*) echo "unknown argument: $1" >&2; exit 2 ;; + *) TARBALL="$1"; shift ;; + esac +done + +ARCH="$(uname -m)" +case "$ARCH" in + x86_64) ;; + aarch64 | arm64) ARCH=aarch64 ;; + *) echo "unsupported architecture $ARCH" >&2; exit 2 ;; +esac + +BASENAME="pdfcraft-$VERSION-linux-$ARCH" +OUT_OCI="$DIST/$BASENAME-oci" + +echo "==> Building Flatpak OCI layout for $BASENAME" +"$HERE/flatpak-bundle.sh" --oci --no-test "${TARBALL:-}" + +[[ -d "$OUT_OCI" ]] || { echo "error: $OUT_OCI layout not generated" >&2; exit 1; } + +REGISTRY="${REGISTRY:-ghcr.io/storytold/pdfcraft}" +TAG="${TAG:-$VERSION-$ARCH}" + +echo "==> OCI layout ready at $OUT_OCI (Target: $REGISTRY:$TAG)" + +if [[ "$PUSH" = 1 ]]; then + command -v skopeo >/dev/null || { echo "error: skopeo is required to push OCI images (apt install skopeo)" >&2; exit 1; } + + AUTH_ARGS=() + if [[ -n "${GITHUB_TOKEN:-}" ]]; then + AUTH_ARGS=(--dest-creds "${GITHUB_ACTOR:-storytold}:$GITHUB_TOKEN") + fi + + echo "==> Pushing to $REGISTRY:$TAG" + skopeo copy --format oci "${AUTH_ARGS[@]}" "oci:$OUT_OCI" "docker://$REGISTRY:$TAG" + + # Also update architecture-specific stable tag + echo "==> Pushing to $REGISTRY:stable-$ARCH" + skopeo copy --format oci "${AUTH_ARGS[@]}" "oci:$OUT_OCI" "docker://$REGISTRY:stable-$ARCH" + + echo "==> Push complete" +fi diff --git a/packaging/linux/flatpak/ai.storyteller.pdfcraft.bundle.yml b/packaging/linux/flatpak/ai.storyteller.pdfcraft.bundle.yml index 10aedeeee0..065945c6ea 100644 --- a/packaging/linux/flatpak/ai.storyteller.pdfcraft.bundle.yml +++ b/packaging/linux/flatpak/ai.storyteller.pdfcraft.bundle.yml @@ -17,6 +17,7 @@ runtime-version: '25.08' sdk: org.freedesktop.Sdk command: pdfcraft finish-args: + - --filesystem=xdg-data/ArtCraft/versions:ro # Display: Wayland first; X11 only when no Wayland compositor is running. - --socket=wayland - --socket=fallback-x11 @@ -24,18 +25,76 @@ finish-args: - --share=ipc # GPU access for the wgpu renderer (Vulkan, or GL as a fallback). - --device=dri + # Printing: lpstat/lp (the cups-client module) reach the host's CUPS through its socket and + # find driverless network printers through Avahi (`lpstat -e`). Without these the Print + # dialog lists no printers. + - --socket=cups + - --system-talk-name=org.freedesktop.Avahi + # The user's CUPS settings, read-only: their default printer and saved options (lpoptions) + # and a print server named in their client.conf, which lp reaches over the network. + - --filesystem=~/.cups:ro # Open/Save dialogs go through the file-chooser portal (rfd's xdg-portal backend) and # drag-and-drop through the document portal, so picked files work anywhere without broad # filesystem access. Documents and Downloads, where PDFs usually live, are granted so files # passed by path (the command line, Open Recent, "Save" next to the original) work there. - --filesystem=xdg-documents - --filesystem=xdg-download - # Deliberately not granted: network (Flatpak installs update through flatpak, so Help ▸ Check - # for updates reports that GitHub is unreachable; the optional --control channel listens on - # 127.0.0.1 inside the sandbox) and home/host filesystem access. No CUPS socket either: printing + # Network: Help ▸ Check for updates reaches GitHub. The optional --control channel still listens + # on 127.0.0.1 inside the sandbox. + - --share=network + # Deliberately not granted: home/host filesystem access. No CUPS socket either: printing # runs the `lp` command (crates/print), which the freedesktop runtime doesn't ship, so Print # reports that the spooler is unavailable until it goes through the print portal. +# The release runner's flatpak-builder has no eu-strip (elfutils), which its debuginfo pass runs +# on cups-client's unstripped tools, in whichever module comes first after they're installed. The +# app's own binaries come from the release tarball, already stripped. +build-options: + no-debuginfo: true modules: + # CUPS client tools for craft-print: the runtime has libcups but not lpstat/lp/lpoptions. + # Linked statically against this release's libcups, so a runtime update can't break them; + # the socket path matches the host's, which --socket=cups mounts. + - name: cups-client + buildsystem: simple + build-commands: + - ./configure --prefix=/app --sysconfdir=/etc --localstatedir=/var --with-rundir=/run/cups + --with-domainsocket=/run/cups/cups.sock --disable-shared --with-components=libcups + --with-dnssd=avahi --with-tls=gnutls --disable-pam --disable-dbus + - make -C cups libs + - make -C systemv lp lpoptions lpstat + - install -Dm755 -t /app/libexec/cups systemv/lp systemv/lpoptions systemv/lpstat + - for tool in lp lpoptions lpstat; do install -Dm755 cups-socket.sh "/app/bin/$tool"; done + - install -Dm644 -t /app/share/licenses/cups LICENSE NOTICE + sources: + - type: archive + url: https://github.com/OpenPrinting/cups/releases/download/v2.4.20/cups-2.4.20-source.tar.gz + sha256: ab4d9cd7f3e58060091d2b24972223d6401675f11c49b65abf4f6ef31dea22ff + # Runs each tool. Flatpak mounts the host's CUPS socket at /var/run/cups/cups.sock, choosing + # it as flatpak-run-cups.c does from the host's CUPS_SERVER, else the first line spelled + # exactly `ServerName /path` in ~/.cups/client.conf, else the default; it mounts nothing + # when that socket is missing. CUPS reads the keyword in any case and takes the last + # ServerName. When CUPS' socket from client.conf is the one Flatpak chose, it is pointed at + # the mount; when it isn't, the tool fails rather than reach a different server. A + # CUPS_SERVER is left alone: one set by `flatpak run --env` may not be what was mounted. + - type: script + dest-filename: cups-socket.sh + commands: + - 'conf="$HOME/.cups/client.conf"' + - 'if [ -z "${CUPS_SERVER:-}" ] && [ -r "$conf" ]; then' + - ' server=$(sed -n ''s/^[[:space:]]*[Ss][Ee][Rr][Vv][Ee][Rr][Nn][Aa][Mm][Ee][[:space:]]\{1,\}\([^[:space:]]*\).*/\1/p'' "$conf" | tail -n 1)' + - ' case "$server" in' + - ' /*)' + - ' mounted=$(sed -n ''s/^[[:space:]]*ServerName \(.*\)$/\1/p'' "$conf" | sed ''s/^[[:space:]]*//'' | grep -m 1 ''^/[^:]*$'')' + - ' if [ "$mounted" = "$server" ]; then' + - ' export CUPS_SERVER=/var/run/cups/cups.sock' + - ' else' + - ' echo "${0##*/}: ~/.cups/client.conf names the CUPS socket $server, but Flatpak gave this app ${mounted:-a different one}; give each socket one ServerName line, spelled ServerName" >&2' + - ' exit 1' + - ' fi' + - ' ;;' + - ' esac' + - 'fi' + - 'exec "/app/libexec/cups/${0##*/}" "$@"' - name: pdfcraft buildsystem: simple build-commands: diff --git a/packaging/linux/flatpak/ai.storyteller.pdfcraft.yml b/packaging/linux/flatpak/ai.storyteller.pdfcraft.yml index 8ae41ae976..5d30c5a4e3 100644 --- a/packaging/linux/flatpak/ai.storyteller.pdfcraft.yml +++ b/packaging/linux/flatpak/ai.storyteller.pdfcraft.yml @@ -18,6 +18,7 @@ sdk-extensions: - org.freedesktop.Sdk.Extension.rust-stable command: pdfcraft finish-args: + - --filesystem=xdg-data/ArtCraft/versions:ro # Display: Wayland first; X11 only when no Wayland compositor is running. - --socket=wayland - --socket=fallback-x11 @@ -25,20 +26,73 @@ finish-args: - --share=ipc # GPU access for the wgpu renderer (Vulkan, or GL as a fallback). - --device=dri + # Printing: lpstat/lp (the cups-client module) reach the host's CUPS through its socket and + # find driverless network printers through Avahi (`lpstat -e`). Without these the Print + # dialog lists no printers. + - --socket=cups + - --system-talk-name=org.freedesktop.Avahi + # The user's CUPS settings, read-only: their default printer and saved options (lpoptions) + # and a print server named in their client.conf, which lp reaches over the network. + - --filesystem=~/.cups:ro # Open/Save dialogs go through the file-chooser portal (rfd's xdg-portal backend) and # drag-and-drop through the document portal, so picked files work anywhere without broad # filesystem access. Documents and Downloads, where PDFs usually live, are granted so files # passed by path (the command line, Open Recent, "Save" next to the original) work there. - --filesystem=xdg-documents - --filesystem=xdg-download - # Deliberately not granted: network (Flatpak installs update through flatpak, so Help ▸ Check - # for updates reports that GitHub is unreachable; the optional --control channel listens on - # 127.0.0.1 inside the sandbox) and home/host filesystem access. No CUPS socket either: printing + # Network: Help ▸ Check for updates reaches GitHub. The optional --control channel still listens + # on 127.0.0.1 inside the sandbox. + - --share=network + # Deliberately not granted: home/host filesystem access. No CUPS socket either: printing # runs the `lp` command (crates/print), which the freedesktop runtime doesn't ship, so Print # reports that the spooler is unavailable until it goes through the print portal. build-options: append-path: /usr/lib/sdk/rust-stable/bin modules: + # CUPS client tools for craft-print: the runtime has libcups but not lpstat/lp/lpoptions. + # Linked statically against this release's libcups, so a runtime update can't break them; + # the socket path matches the host's, which --socket=cups mounts. + - name: cups-client + buildsystem: simple + build-commands: + - ./configure --prefix=/app --sysconfdir=/etc --localstatedir=/var --with-rundir=/run/cups + --with-domainsocket=/run/cups/cups.sock --disable-shared --with-components=libcups + --with-dnssd=avahi --with-tls=gnutls --disable-pam --disable-dbus + - make -C cups libs + - make -C systemv lp lpoptions lpstat + - install -Dm755 -t /app/libexec/cups systemv/lp systemv/lpoptions systemv/lpstat + - for tool in lp lpoptions lpstat; do install -Dm755 cups-socket.sh "/app/bin/$tool"; done + - install -Dm644 -t /app/share/licenses/cups LICENSE NOTICE + sources: + - type: archive + url: https://github.com/OpenPrinting/cups/releases/download/v2.4.20/cups-2.4.20-source.tar.gz + sha256: ab4d9cd7f3e58060091d2b24972223d6401675f11c49b65abf4f6ef31dea22ff + # Runs each tool. Flatpak mounts the host's CUPS socket at /var/run/cups/cups.sock, choosing + # it as flatpak-run-cups.c does from the host's CUPS_SERVER, else the first line spelled + # exactly `ServerName /path` in ~/.cups/client.conf, else the default; it mounts nothing + # when that socket is missing. CUPS reads the keyword in any case and takes the last + # ServerName. When CUPS' socket from client.conf is the one Flatpak chose, it is pointed at + # the mount; when it isn't, the tool fails rather than reach a different server. A + # CUPS_SERVER is left alone: one set by `flatpak run --env` may not be what was mounted. + - type: script + dest-filename: cups-socket.sh + commands: + - 'conf="$HOME/.cups/client.conf"' + - 'if [ -z "${CUPS_SERVER:-}" ] && [ -r "$conf" ]; then' + - ' server=$(sed -n ''s/^[[:space:]]*[Ss][Ee][Rr][Vv][Ee][Rr][Nn][Aa][Mm][Ee][[:space:]]\{1,\}\([^[:space:]]*\).*/\1/p'' "$conf" | tail -n 1)' + - ' case "$server" in' + - ' /*)' + - ' mounted=$(sed -n ''s/^[[:space:]]*ServerName \(.*\)$/\1/p'' "$conf" | sed ''s/^[[:space:]]*//'' | grep -m 1 ''^/[^:]*$'')' + - ' if [ "$mounted" = "$server" ]; then' + - ' export CUPS_SERVER=/var/run/cups/cups.sock' + - ' else' + - ' echo "${0##*/}: ~/.cups/client.conf names the CUPS socket $server, but Flatpak gave this app ${mounted:-a different one}; give each socket one ServerName line, spelled ServerName" >&2' + - ' exit 1' + - ' fi' + - ' ;;' + - ' esac' + - 'fi' + - 'exec "/app/libexec/cups/${0##*/}" "$@"' - name: pdfcraft buildsystem: simple build-options: diff --git a/packaging/macos/dmg/README.md b/packaging/macos/dmg/README.md index 460f7bdebd..ee541bdd25 100644 --- a/packaging/macos/dmg/README.md +++ b/packaging/macos/dmg/README.md @@ -7,7 +7,7 @@ scripting on CI, nothing extra installed in the signing job). | File | What | |---|---| -| `background.svg` | Source of the background: the app icon (`assets/app-icon/pdfcraft.svg`, linked, not copied) cropped as a cover on the PdfCraft colour field (`#12a58a`), Ink and Paper. Its text (Inter, JetBrains Mono) is outlined, so rendering it needs no fonts. No paper grain: noise doesn't compress, and this keeps `background.tiff` small. | +| `background.svg` | Source of the background: the app icon (`assets/app-icon/pdfcraft.svg`, linked, not copied) cropped as a cover on the PdfCraft colour field (`#b70228`), Ink and Paper. Its text (Inter, JetBrains Mono) is outlined, so rendering it needs no fonts. No paper grain: noise doesn't compress, and this keeps `background.tiff` small. | | `background.tiff` | The background at 1x (660 × 400 px, 72 dpi) and 2x (1320 × 800 px, 144 dpi) in one HiDPI TIFF (16-colour palette, Deflate, sRGB). Goes to `.background/background.tiff`. | | `dmg-layout.DS_Store` | Finder's view settings for the volume: window size, icon size 128, PdfCraft.app at (326, 205), `Applications` at (574, 205), and the background. Goes to `.DS_Store` in the image; named so it isn't mistaken for (or ignored like) a Finder-generated `.DS_Store`. | | `generate.py` | Writes `background.tiff` and `dmg-layout.DS_Store` from the SVG and the layout above. | diff --git a/packaging/macos/dmg/background.svg b/packaging/macos/dmg/background.svg index 749b6a494f..da530854b4 100644 --- a/packaging/macos/dmg/background.svg +++ b/packaging/macos/dmg/background.svg @@ -6,14 +6,14 @@ - + - + - + diff --git a/packaging/macos/dmg/background.tiff b/packaging/macos/dmg/background.tiff index 119a02d338..515e97c935 100644 Binary files a/packaging/macos/dmg/background.tiff and b/packaging/macos/dmg/background.tiff differ diff --git a/packaging/macos/package.sh b/packaging/macos/package.sh index 50701e0c16..ef1b7f6c70 100755 --- a/packaging/macos/package.sh +++ b/packaging/macos/package.sh @@ -80,6 +80,21 @@ sign() { codesign --force --sign "$IDENTITY" ${kc[@]+"${kc[@]}"} "${ts[@]}" "$@" } +# `stapler staple` and `stapler validate` both fetch the ticket from Apple's CloudKit, and a dropped +# connection there (NSURLErrorDomain -1005, "Error 68") fails them although notarization worked. +# Both are idempotent, so retry with a pause; a missing or bad ticket still fails every try. +stapler_retry() { + local try + for try in 1 2 3 4; do + xcrun stapler "$1" "$2" && return 0 + [[ "$try" = 4 ]] && break + echo "stapler $1 failed (try $try of 4); retrying in $((try * 20)) s" >&2 + sleep $((try * 20)) + done + echo "error: stapler $1 $(basename "$2") failed 4 times" >&2 + exit 1 +} + notarize() { local file="$1" out id status echo "==> notarizing $(basename "$file") (this can take a few minutes)" @@ -127,8 +142,8 @@ codesign --verify --strict --deep --verbose=2 "$APP" if [[ "$NOTARIZE" = 1 ]]; then ditto -c -k --keepParent "$APP" "$WORK/PdfCraft-notarize.zip" notarize "$WORK/PdfCraft-notarize.zip" - xcrun stapler staple "$APP" - xcrun stapler validate "$APP" + stapler_retry staple "$APP" + stapler_retry validate "$APP" spctl --assess --type execute -vvv "$APP" fi @@ -178,8 +193,8 @@ fi hdiutil_retry detach detach "$MOUNT" >/dev/null if [[ "$NOTARIZE" = 1 ]]; then notarize "$DMG" - xcrun stapler staple "$DMG" - xcrun stapler validate "$DMG" + stapler_retry staple "$DMG" + stapler_retry validate "$DMG" spctl --assess --type open --context context:primary-signature -vvv "$DMG" fi diff --git a/packaging/windows/INSTALLATION.md b/packaging/windows/INSTALLATION.md new file mode 100644 index 0000000000..982a5b4202 --- /dev/null +++ b/packaging/windows/INSTALLATION.md @@ -0,0 +1,35 @@ +# Windows installation policy checks + +New installations default to the current account under `Programs\ArtCraft\`. +The first setup page offers installation for everyone. Scope is selected before duplicate +checks and directory costing. An update uses the existing context's remembered exact folder; +`INSTALLFOLDER` supplied explicitly is never replaced. It is a secure MSI property so the +chosen folder reaches the elevated installer. Silent machine updates pass `ALLUSERS=1`. +A cross-context copy is refused; changing context means uninstalling the old copy first. + +App registration follows the selected context. Existing optional shortcuts and file-handler +choices stay app-owned. No new cleanup operation or model/settings migration is introduced. +Packaged resources remain package-owned; separately downloaded shared models are not caches +and are outside this installer's ownership. + +## Repeatable metadata tests + +On Windows with WiX 5.0.2 and its matching UI/Util extensions: + +```powershell +./packaging/windows/test-install-policy.ps1 -OutputDirectory ./target/install-policy-tests +``` + +The script builds x64, x86 and arm64 packages with deliberately non-executable payloads. It +uses the actual package source and compiled database. A safe Windows Installer session checks +24 scope/registration/maintenance cases, remembered and explicit custom folders, sequencing, +upgrade identity and the original registration inventory recorded in `install-policy.json`. +The release packaging script runs the compiled checker before signing. Packaging CI runs the +small fixture builds without compiling the Rust application. + +Never install or distribute the `NOT-INSTALLABLE.msi` fixtures as an app. Passing these checks +does not prove real installation, elevation, legacy-version upgrades, registry writes, optional +shortcut/menu behaviour, cancellation, rollback or uninstall. Those still require the real +payload in an authorised clean Windows test environment. Cross-architecture metadata is not +execution evidence on those architectures. Existing open installer/association PRs must be +reconciled before publication. Nothing here changes macOS/Linux native packaging conventions. diff --git a/packaging/windows/check-install-policy.ps1 b/packaging/windows/check-install-policy.ps1 new file mode 100644 index 0000000000..cc9416e9c9 --- /dev/null +++ b/packaging/windows/check-install-policy.ps1 @@ -0,0 +1,296 @@ +<# +.SYNOPSIS + Check the compiled installation policy without installing any product. +.DESCRIPTION + Uses Windows Installer's safe session (OpenPackage option 1). Only property-setting custom + actions are exercised. No AppSearch, install, repair, removal or file-writing action runs. +#> +param([Parameter(Mandatory = $true)] [string] $MsiPath) +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$policy = Get-Content -Raw -LiteralPath (Join-Path $PSScriptRoot 'install-policy.json') | ConvertFrom-Json +function Read-Rows($Database, [string] $Sql, [int] $Columns) { + $view = $Database.OpenView($Sql) + try { + [void]$view.Execute() + while ($null -ne ($record = $view.Fetch())) { + try { + $values = @(for ($i = 1; $i -le $Columns; $i++) { + $record.GetType().InvokeMember('StringData', 'GetProperty', $null, $record, @($i)) + }) + ,$values + } finally { [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($record) } + } + } finally { + [void]$view.Close() + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($view) + } +} +function Set-SessionProperty($Session, [string] $Name, [string] $Value) { + [void]$Session.GetType().InvokeMember('Property', 'SetProperty', $null, $Session, @($Name, $Value)) +} +function Get-SessionProperty($Session, [string] $Name) { + $Session.GetType().InvokeMember('Property', 'GetProperty', $null, $Session, @($Name)) +} +function Get-PackageTemplate($Database) { + # Summary information property 7 is the platform: "Intel;1033", "x64;1033" or "Arm64;1033". + $summary = $Database.GetType().InvokeMember('SummaryInformation', 'GetProperty', $null, $Database, @(0)) + try { [string]$summary.GetType().InvokeMember('Property', 'GetProperty', $null, $summary, @(7)) } + finally { [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($summary) } +} +$installer = $null; $database = $null; $session = $null +try { + $installer = New-Object -ComObject WindowsInstaller.Installer + $file = (Resolve-Path -LiteralPath $MsiPath).Path + $database = $installer.OpenDatabase($file, 0) + $session = $installer.OpenPackage($file, 1) + $properties = @{} + foreach ($row in (Read-Rows $database 'SELECT `Property`, `Value` FROM `Property`' 2)) { $properties[$row[0]] = $row[1] } + $secureProperties = @($properties['SecureCustomProperties'] -split ';') + if ('INSTALLFOLDER' -notin $secureProperties) { + throw 'The selected installation folder must survive elevation (secure INSTALLFOLDER).' + } + if ($policy.mode -eq 'perUser') { + if ($properties.ContainsKey('ALLUSERS')) { throw 'Launcher must remain per-user; machine self-update is not part of this change.' } + if ($properties['UpgradeCode'].Trim('{}') -ne $policy.upgradeCode) { throw 'Launcher upgrade identity changed.' } + $dirs = @{} + foreach ($row in (Read-Rows $database 'SELECT `Directory`, `Directory_Parent`, `DefaultDir` FROM `Directory`' 3)) { $dirs[$row[0]] = @($row[1],$row[2]) } + if ($dirs['INSTALLFOLDER'][0] -ne 'CraftSuiteFolder' -or $dirs['CraftSuiteFolder'][1] -ne 'ArtCraft' -or + $dirs['CraftSuiteFolder'][0] -ne 'LocalProgramsFolder' -or $dirs['LocalProgramsFolder'][0] -ne 'LocalAppDataFolder') { + throw 'Launcher new-install folder must be under the per-user ArtCraft suite root.' + } + foreach ($explicit in @('', 'F:\Chosen exact launcher')) { + Set-SessionProperty $session 'PREVIOUSINSTALLFOLDER' 'E:\Existing exact launcher' + Set-SessionProperty $session 'INSTALLFOLDER' $explicit + $row = @(Read-Rows $database 'SELECT `Condition` FROM `InstallUISequence` WHERE `Action` = ''CraftRestoreLauncherFolder''' 1)[0] + if ($session.EvaluateCondition($row[0]) -eq 1) { + if ($session.DoAction('CraftRestoreLauncherFolder') -ne 1) { throw 'Launcher restore action failed.' } + } + $expected = if ($explicit) { $explicit } else { 'E:\Existing exact launcher' } + if ((Get-SessionProperty $session 'INSTALLFOLDER') -ne $expected) { throw 'Launcher custom or existing path was lost.' } + } + $rows = @(Read-Rows $database 'SELECT `Root`, `Key`, `Name`, `Value` FROM `Registry`' 4) + if (@($rows | Where-Object { $_[0] -ne '1' }).Count) { throw 'Launcher registration must stay under the current account.' } + foreach ($entry in $policy.registry) { + if (@($rows | Where-Object { $_[0] -eq $entry[0] -and $_[1] -eq $entry[1] -and $_[2] -eq $entry[2] -and $_[3] -eq $entry[3] }).Count -ne 1) { + throw 'Launcher registration or shortcut preference changed unexpectedly.' + } + } + Write-Output 'PASS ArtCraft Launcher: per-user scope, suite folder, remembered/custom paths and existing registration. No installation performed.' + return + } + if ($properties['ALLUSERS'] -ne '2' -or $properties['MSIINSTALLPERUSER'] -ne '1') { + throw 'Policy regression: new packages must default to the per-user dual-scope context.' + } + if ($properties['UpgradeCode'].Trim('{}') -ne $policy.upgradeCode) { throw 'The existing upgrade identity must not change.' } + $directories = @{} + foreach ($row in (Read-Rows $database 'SELECT `Directory`, `Directory_Parent`, `DefaultDir` FROM `Directory`' 3)) { + $directories[$row[0]] = @($row[1], $row[2]) + } + if ($directories['INSTALLFOLDER'][0] -ne 'CraftSuiteFolder' -or + $directories['CraftSuiteFolder'][1] -ne 'ArtCraft') { throw 'Expected the app under the ArtCraft suite folder.' } + $programs = $directories['CraftSuiteFolder'][0] + if ($programs -eq 'ProgramFiles6432Folder') { $programs = $directories[$programs][0] } + if ($programs -notin 'ProgramFilesFolder', 'ProgramFiles64Folder') { + throw 'Use Windows Installer native folder redirection, not a hard-coded profile path.' + } + $guards = @(Read-Rows $database 'SELECT `Condition`, `Description` FROM `LaunchCondition`' 2 | + Where-Object { $_[0] -match 'CRAFT_(USER|MACHINE)_DIR' }) + if ($guards.Count -ne 2) { throw 'Expected both duplicate-installation guards.' } + $cases = 0 + foreach ($scope in @('', '2', '1')) { + foreach ($user in @('', 'D:\Existing user app')) { + foreach ($machine in @('', 'E:\Existing machine app')) { + foreach ($installed in @('', '1')) { + Set-SessionProperty $session 'ALLUSERS' $scope + Set-SessionProperty $session 'Installed' $installed + Set-SessionProperty $session 'CRAFT_USER_DIR' $user + Set-SessionProperty $session 'CRAFT_MACHINE_DIR' $machine + $allowed = $true + foreach ($guard in $guards) { + $value = $session.EvaluateCondition($guard[0]) + if ($value -notin 0, 1) { throw 'Invalid duplicate guard.' } + if ($value -eq 0) { $allowed = $false } + } + $opposite = if ($scope -eq '1') { $user } else { $machine } + $expected = [bool]$installed -or -not [bool]$opposite + if ($allowed -ne $expected) { throw "Scope regression: ALLUSERS='$scope', user=$([bool]$user), machine=$([bool]$machine), maintenance=$([bool]$installed)." } + $cases++ + } + } + } + } + # Execute the actual authored type-51 actions in a safe session, not copied resolver logic. + foreach ($scope in @('', '2', '1')) { + foreach ($explicit in @('', 'F:\Chosen exact app')) { + Set-SessionProperty $session 'ALLUSERS' $scope + Set-SessionProperty $session 'INSTALLFOLDER' $explicit + Set-SessionProperty $session 'CRAFT_USER_DIR' 'D:\Existing user app' + Set-SessionProperty $session 'CRAFT_MACHINE_DIR' 'E:\Existing machine app' + $action = if ($scope -eq '1') { 'CraftRestoreMachineFolder' } else { 'CraftRestoreUserFolder' } + # Evaluate the compiled condition, so the test exercises it instead of calling the action directly. + $restore = @(Read-Rows $database ('SELECT `Condition` FROM `InstallExecuteSequence` WHERE `Action` = ''{0}''' -f $action) 1)[0] + $selected = $session.EvaluateCondition($restore[0]) -eq 1 + if ($selected -eq [bool]$explicit) { throw "$action must run only when no folder was supplied (condition: $($restore[0]))." } + if ($selected -and $session.DoAction($action) -ne 1) { throw 'Remembered-folder property action failed.' } + $expected = if ($explicit) { $explicit } elseif ($scope -eq '1') { 'E:\Existing machine app' } else { 'D:\Existing user app' } + if ((Get-SessionProperty $session 'INSTALLFOLDER') -ne $expected) { throw 'Custom or remembered folder was not preserved.' } + } + } + $ui = @{} + foreach ($row in (Read-Rows $database 'SELECT `Action`, `Sequence` FROM `InstallUISequence`' 2)) { $ui[$row[0]] = [int]$row[1] } + if (-not ($ui['AppSearch'] -lt $ui['CraftPreferMachineScope'] -and + $ui['CraftPreferMachineScope'] -lt $ui['CraftScopeDlg'] -and + $ui['CraftScopeDlg'] -lt $ui['FindRelatedProducts'] -and + $ui['FindRelatedProducts'] -lt $ui['LaunchConditions'] -and + $ui['CraftScopeDlg'] -lt $ui['CraftRestoreUserFolder'] -and $ui['CraftRestoreUserFolder'] -lt $ui['CostInitialize'])) { + throw 'Scope must be chosen after AppSearch and before FindRelatedProducts, launch checks and costing.' + } + # Windows Installer resolves Program Files for the context it started in, before the wizard's scope choice. + # The wizard therefore sets the new-install folder itself, and only in the wizard. + $defaults = @(Read-Rows $database 'SELECT `Action`, `Sequence`, `Condition` FROM `InstallUISequence`' 3 | Where-Object { $_[0] -like 'CraftDefault*' }) + if ($defaults.Count -lt 2) { throw 'The wizard must set the new-install folder after the scope page; Program Files is resolved before the choice.' } + foreach ($row in $defaults) { + if (-not ($ui['CraftScopeDlg'] -lt [int]$row[1] -and [int]$row[1] -lt $ui['CostInitialize'])) { throw "$($row[0]) must run after the scope page and before costing." } + } + if (@(Read-Rows $database 'SELECT `Action` FROM `InstallExecuteSequence`' 1 | Where-Object { $_[0] -like 'CraftDefault*' }).Count) { + throw 'Silent installs must not use the wizard-only default folder actions.' + } + # The machine default comes from the environment of the package architecture, not from a folder name. + # x86 packages use Program Files (x86) on 64-bit Windows; x64 and Arm64 packages use the 64-bit root. + $machineRoot = if ((Get-PackageTemplate $database) -match '^Intel') { + if (Get-SessionProperty $session 'VersionNT64') { ${env:ProgramFiles(x86)} } else { $env:ProgramFiles } + } elseif ($env:ProgramW6432) { $env:ProgramW6432 } else { $env:ProgramFiles } + $machineFolder = (Join-Path $machineRoot "ArtCraft\$($policy.name)") + '\' + foreach ($scope in @('1', '')) { + Set-SessionProperty $session 'ALLUSERS' $scope + Set-SessionProperty $session 'INSTALLFOLDER' '' + Set-SessionProperty $session 'CRAFT_MACHINE_DIR' '' + Set-SessionProperty $session 'CRAFT_USER_DIR' '' + foreach ($row in @($defaults | Sort-Object { [int]$_[1] })) { + if ($session.EvaluateCondition($row[2]) -eq 1 -and $session.DoAction($row[0]) -ne 1) { throw "$($row[0]) failed." } + } + $folder = Get-SessionProperty $session 'INSTALLFOLDER' + $wanted = if ($scope -eq '1') { $machineFolder } else { (Join-Path $env:LOCALAPPDATA "Programs\ArtCraft\$($policy.name)") + '\' } + if ($folder -ine $wanted) { throw "Wizard default folder for ALLUSERS='$scope' is '$folder', expected '$wanted'." } + } + $execute = @{} + foreach ($row in (Read-Rows $database 'SELECT `Action`, `Sequence` FROM `InstallExecuteSequence`' 2)) { $execute[$row[0]] = [int]$row[1] } + foreach ($action in @('CraftRestoreUserFolder','CraftRestoreMachineFolder')) { + if (-not ($execute['AppSearch'] -lt $execute[$action] -and $execute[$action] -lt $execute['CostInitialize'])) { + throw 'Silent updates must restore the folder before directory costing.' + } + } + foreach ($resolver in @('CraftUFromLegacyExe','CraftMFromLegacyExe')) { + if (-not ($execute['AppSearch'] -lt $execute[$resolver] -and $execute[$resolver] -lt $execute['LaunchConditions'])) { + throw 'Both legacy folder resolvers must run after AppSearch and before the launch checks.' + } + } + # Switching the first-page choice must reset BOTH properties, including a back-and-forth change. + $events = @(Read-Rows $database 'SELECT `Event`, `Argument`, `Condition`, `Ordering` FROM `ControlEvent` WHERE `Dialog_` = ''CraftScopeDlg'' AND `Control_` = ''Next'' ORDER BY `Ordering`' 4) + # Model the full-UI upgrade entry with an existing machine-wide copy. Run + # the actual preference action, then the compiled Next events before discovery. + Set-SessionProperty $session 'ALLUSERS' '2' + Set-SessionProperty $session 'CRAFT_SCOPE' 'user' + Set-SessionProperty $session 'CRAFT_USER_DIR' '' + Set-SessionProperty $session 'CRAFT_MACHINE_DIR' 'E:\Existing machine app' + $preference = @(Read-Rows $database 'SELECT `Condition` FROM `InstallUISequence` WHERE `Action` = ''CraftPreferMachineScope''' 1)[0] + if ($session.EvaluateCondition($preference[0]) -ne 1 -or $session.DoAction('CraftPreferMachineScope') -ne 1 -or + (Get-SessionProperty $session 'CRAFT_SCOPE') -ne 'machine') { + throw 'Full-UI machine upgrades must initially select the existing machine context.' + } + foreach ($choice in @('machine','user','machine','user')) { + Set-SessionProperty $session 'CRAFT_SCOPE' $choice + foreach ($event in $events) { + if ($event[0] -notmatch '^\[([^\]]+)\]$') { continue } + $property = $Matches[1] + if ($session.EvaluateCondition($event[2]) -eq 1) { + Set-SessionProperty $session $property $(if ($event[1] -eq '{}') { '' } else { $event[1] }) + } + } + $allUsers = Get-SessionProperty $session 'ALLUSERS' + $perUser = Get-SessionProperty $session 'MSIINSTALLPERUSER' + if (($choice -eq 'machine' -and ($allUsers -ne '1' -or $perUser)) -or + ($choice -eq 'user' -and ($allUsers -or $perUser -ne '1'))) { + throw 'Scope selector does not reset the requested installation context.' + } + } + $actions = @(Read-Rows $database 'SELECT `Action`, `Source`, `Target`, `Type` FROM `CustomAction`' 4) + $savedOptions = @($actions | Where-Object { $_[0] -match '^CraftLoadOption[0-9]+M$' }) + foreach ($option in $savedOptions) { + if ([int]$option[3] -ne 51) { throw 'Saved-option test may run only property-setting actions.' } + $number = [regex]::Match($option[0], '[0-9]+').Value + foreach ($scope in @('2','1')) { + Set-SessionProperty $session 'ALLUSERS' $scope + Set-SessionProperty $session "CRAFT_OPTION_${number}_M" 'saved-machine-choice' + Set-SessionProperty $session "CRAFT_OPTION_${number}_U" 'saved-user-choice' + Set-SessionProperty $session $option[1] '' + $action = "CraftLoadOption$number" + $(if ($scope -eq '1') { 'M' } else { 'U' }) + if ($session.DoAction($action) -ne 1) { throw 'Saved-option property action failed.' } + $expected = if ($scope -eq '1') { 'saved-machine-choice' } else { 'saved-user-choice' } + if ((Get-SessionProperty $session $option[1]) -ne $expected) { throw 'Saved installer choices leaked across contexts.' } + } + } + # Follow the compiled control conditions, not the source's intended appearance. + $controls = @(Read-Rows $database 'SELECT `Dialog_`, `Control`, `Type`, `Attributes`, `Text` FROM `Control`' 5) + $conditions = @() + if (@(Read-Rows $database 'SELECT `Name` FROM `_Tables` WHERE `Name` = ''ControlCondition''' 1).Count) { + $conditions = @(Read-Rows $database 'SELECT `Dialog_`, `Control_`, `Action`, `Condition` FROM `ControlCondition`' 4) + } + function Test-ControlVisible($Control) { + $visible = ([int]$Control[3] -band 1) -ne 0 + foreach ($condition in $conditions) { + if ($condition[0] -eq $Control[0] -and $condition[1] -eq $Control[1] -and $session.EvaluateCondition($condition[3]) -eq 1) { + if ($condition[2] -eq 'Show') { $visible = $true } + if ($condition[2] -eq 'Hide') { $visible = $false } + } + } + return $visible + } + foreach ($button in @($controls | Where-Object { $_[0] -in 'InstallWelcome','InstallMaintenance','DesktopShortcutDlg' -and ([int]$_[3] -band 8388608) -ne 0 })) { + $plain = @($controls | Where-Object { $_[0] -eq $button[0] -and $_[1] -eq ($button[1] + 'User') }) + if ($plain.Count -ne 1 -or ([int]$plain[0][3] -band 8388608)) { throw 'Scope-aware buttons need an unshielded per-user counterpart.' } + foreach ($scope in @('', '2', '1')) { + Set-SessionProperty $session 'ALLUSERS' $scope + if ((Test-ControlVisible $button) -ne ($scope -eq '1') -or (Test-ControlVisible $plain[0]) -ne ($scope -ne '1')) { + throw 'The elevation shield must follow the selected scope.' + } + } + $dialog = $button[0]; $shieldId = $button[1]; $plainId = $plain[0][1] + $shieldEvents = @(Read-Rows $database "SELECT ``Event``, ``Argument``, ``Condition``, ``Ordering`` FROM ``ControlEvent`` WHERE ``Dialog_`` = '$dialog' AND ``Control_`` = '$shieldId' ORDER BY ``Ordering``" 4 | ForEach-Object { $_ -join '|' }) + $plainEvents = @(Read-Rows $database "SELECT ``Event``, ``Argument``, ``Condition``, ``Ordering`` FROM ``ControlEvent`` WHERE ``Dialog_`` = '$dialog' AND ``Control_`` = '$plainId' ORDER BY ``Ordering``" 4 | ForEach-Object { $_ -join '|' }) + if (($shieldEvents -join "`n") -ne ($plainEvents -join "`n")) { throw 'Per-user and machine buttons must perform the same operation.' } + } + foreach ($scope in @('', '2', '1')) { + Set-SessionProperty $session 'ALLUSERS' $scope + foreach ($text in @($controls | Where-Object { $_[4] -like '*for all users*' })) { + if ((Test-ControlVisible $text) -ne ($scope -eq '1')) { throw 'The welcome-page scope text must follow the selected scope.' } + } + } + if ($policy.id -in 'deckcraft','filmcraft','soundcraft') { + foreach ($route in @(@('WelcomeDlg','Next','InstallDirDlg'),@('InstallDirDlg','Back','WelcomeDlg'))) { + $dialog = $route[0]; $control = $route[1] + $routes = @(Read-Rows $database "SELECT ``Argument``, ``Condition`` FROM ``ControlEvent`` WHERE ``Dialog_`` = '$dialog' AND ``Control_`` = '$control' AND ``Event`` = 'NewDialog' ORDER BY ``Ordering``" 2) + Set-SessionProperty $session 'Installed' '' + $destination = '' + foreach ($event in $routes) { if ($session.EvaluateCondition($event[1]) -eq 1) { $destination = $event[0] } } + if ($destination -ne $route[2]) { throw 'Skipping the licence page must work in both navigation directions.' } + } + } + $registry = @(Read-Rows $database 'SELECT `Root`, `Key`, `Name`, `Value` FROM `Registry`' 4) + if (@($registry | Where-Object { $_[0] -in '1', '2' }).Count) { throw 'Registration must follow the selected context, not force HKCU or HKLM.' } + if (@($registry | Where-Object { $_[0] -eq '0' -and $_[1] -match '^Software\\Classes(\\|$)' }).Count) { + throw 'HKCR keys must be relative to Classes.' + } + foreach ($entry in $policy.registry) { + $found = @($registry | Where-Object { $_[0] -eq $entry[0] -and $_[1] -eq $entry[1] -and $_[2] -eq $entry[2] -and $_[3] -eq $entry[3] }) + if ($found.Count -ne 1) { throw "Lost or changed registration: $($entry[1]) / $($entry[2])" } + } + $installDir = @($registry | Where-Object { $_[0] -eq '-1' -and $_[1] -eq "Software\$($policy.name)" -and $_[2] -eq 'InstallDir' -and $_[3] -eq '[INSTALLFOLDER]' }) + if ($installDir.Count -ne 1) { throw 'A context-sensitive installation-folder record is required.' } + Write-Output "PASS $($policy.name): $cases scope cases, folder preservation, action sequencing and registry contract. No installation performed." +} finally { + foreach ($com in @($session, $database, $installer)) { + if ($null -ne $com) { [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($com) } + } +} diff --git a/packaging/windows/install-policy.json b/packaging/windows/install-policy.json new file mode 100644 index 0000000000..374bd4e5fe --- /dev/null +++ b/packaging/windows/install-policy.json @@ -0,0 +1,329 @@ +{ + "id": "pdfcraft", + "name": "PdfCraft", + "upgradeCode": "EA00C367-7270-4C50-9D8B-A7A5C88AD27F", + "sources": [ + "pdfcraft.wxs", + "installer-ui.wxs" + ], + "icon": "assets/app-icon/pdfcraft.ico", + "localization": [], + "extensions": [], + "payloads": [ + "LICENSE-APACHE", + "LICENSE-MIT", + "LICENSE-document.txt", + "NOTICE", + "pdfcraft-cli.exe", + "pdfcraft.exe" + ], + "registry": [ + [ + "-1", + "Software\\Microsoft\\Windows\\CurrentVersion\\App Paths\\pdfcraft.exe", + "", + "[#PdfcraftExe]" + ], + [ + "-1", + "Software\\Classes\\PdfCraft.Document", + "", + "PDF Document" + ], + [ + "-1", + "Software\\Classes\\PdfCraft.Document\\DefaultIcon", + "", + "\"[#PdfcraftExe]\",-2" + ], + [ + "-1", + "Software\\Classes\\PdfCraft.Document\\shell\\open\\command", + "", + "\"[#PdfcraftExe]\" \"%1\"" + ], + [ + "-1", + "Software\\Classes\\PdfCraft.Document", + "TypeOverlay", + "" + ], + [ + "-1", + "Software\\Classes\\.pdf\\OpenWithProgids", + "PdfCraft.Document", + "" + ], + [ + "-1", + "Software\\Classes\\Applications\\pdfcraft.exe", + "FriendlyAppName", + "PdfCraft" + ], + [ + "-1", + "Software\\Classes\\Applications\\pdfcraft.exe\\DefaultIcon", + "", + "\"[#PdfcraftExe]\",-2" + ], + [ + "-1", + "Software\\Classes\\Applications\\pdfcraft.exe\\SupportedTypes", + ".pdf", + "" + ], + [ + "-1", + "Software\\Classes\\Applications\\pdfcraft.exe\\shell\\open\\command", + "", + "\"[#PdfcraftExe]\" \"%1\"" + ], + [ + "-1", + "Software\\PdfCraft\\Capabilities", + "ApplicationName", + "PdfCraft" + ], + [ + "-1", + "Software\\PdfCraft\\Capabilities", + "ApplicationDescription", + "Open-source PDF workbench" + ], + [ + "-1", + "Software\\PdfCraft\\Capabilities", + "ApplicationIcon", + "[#PdfcraftExe],0" + ], + [ + "-1", + "Software\\PdfCraft\\Capabilities\\FileAssociations", + ".pdf", + "PdfCraft.Document" + ], + [ + "-1", + "Software\\RegisteredApplications", + "PdfCraft", + "Software\\PdfCraft\\Capabilities" + ], + [ + "-1", + "Software\\PdfCraft\\Shortcuts", + "StartMenu", + "#1" + ], + [ + "-1", + "Software\\PdfCraft\\Shortcuts", + "Desktop", + "#1" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.pdf\\shell\\PdfCraft.Edit", + "", + "Edit with PdfCraft" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.pdf\\shell\\PdfCraft.Edit", + "Icon", + "\"[#PdfcraftExe]\",0" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.pdf\\shell\\PdfCraft.Edit", + "MultiSelectModel", + "Document" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.pdf\\shell\\PdfCraft.Edit\\command", + "", + "\"[#PdfcraftExe]\" \"%1\"" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.png\\shell\\PdfCraft.CreatePdf", + "", + "Create PDF with PdfCraft\u2026" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.png\\shell\\PdfCraft.CreatePdf", + "MultiSelectModel", + "Single" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.png\\shell\\PdfCraft.CreatePdf\\command", + "", + "\"[#PdfcraftExe]\" --create-images \"%1\"" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.jpg\\shell\\PdfCraft.CreatePdf", + "", + "Create PDF with PdfCraft\u2026" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.jpg\\shell\\PdfCraft.CreatePdf", + "MultiSelectModel", + "Single" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.jpg\\shell\\PdfCraft.CreatePdf\\command", + "", + "\"[#PdfcraftExe]\" --create-images \"%1\"" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.jpeg\\shell\\PdfCraft.CreatePdf", + "", + "Create PDF with PdfCraft\u2026" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.jpeg\\shell\\PdfCraft.CreatePdf", + "MultiSelectModel", + "Single" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.jpeg\\shell\\PdfCraft.CreatePdf\\command", + "", + "\"[#PdfcraftExe]\" --create-images \"%1\"" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.tif\\shell\\PdfCraft.CreatePdf", + "", + "Create PDF with PdfCraft\u2026" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.tif\\shell\\PdfCraft.CreatePdf", + "MultiSelectModel", + "Single" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.tif\\shell\\PdfCraft.CreatePdf\\command", + "", + "\"[#PdfcraftExe]\" --create-images \"%1\"" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.tiff\\shell\\PdfCraft.CreatePdf", + "", + "Create PDF with PdfCraft\u2026" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.tiff\\shell\\PdfCraft.CreatePdf", + "MultiSelectModel", + "Single" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.tiff\\shell\\PdfCraft.CreatePdf\\command", + "", + "\"[#PdfcraftExe]\" --create-images \"%1\"" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.gif\\shell\\PdfCraft.CreatePdf", + "", + "Create PDF with PdfCraft\u2026" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.gif\\shell\\PdfCraft.CreatePdf", + "MultiSelectModel", + "Single" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.gif\\shell\\PdfCraft.CreatePdf\\command", + "", + "\"[#PdfcraftExe]\" --create-images \"%1\"" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.bmp\\shell\\PdfCraft.CreatePdf", + "", + "Create PDF with PdfCraft\u2026" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.bmp\\shell\\PdfCraft.CreatePdf", + "MultiSelectModel", + "Single" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.bmp\\shell\\PdfCraft.CreatePdf\\command", + "", + "\"[#PdfcraftExe]\" --create-images \"%1\"" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.jp2\\shell\\PdfCraft.CreatePdf", + "", + "Create PDF with PdfCraft\u2026" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.jp2\\shell\\PdfCraft.CreatePdf", + "MultiSelectModel", + "Single" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.jp2\\shell\\PdfCraft.CreatePdf\\command", + "", + "\"[#PdfcraftExe]\" --create-images \"%1\"" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.j2k\\shell\\PdfCraft.CreatePdf", + "", + "Create PDF with PdfCraft\u2026" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.j2k\\shell\\PdfCraft.CreatePdf", + "MultiSelectModel", + "Single" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.j2k\\shell\\PdfCraft.CreatePdf\\command", + "", + "\"[#PdfcraftExe]\" --create-images \"%1\"" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.jpx\\shell\\PdfCraft.CreatePdf", + "", + "Create PDF with PdfCraft\u2026" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.jpx\\shell\\PdfCraft.CreatePdf", + "MultiSelectModel", + "Single" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.jpx\\shell\\PdfCraft.CreatePdf\\command", + "", + "\"[#PdfcraftExe]\" --create-images \"%1\"" + ] + ], + "mode": "dualScope" +} diff --git a/packaging/windows/install-policy.wxi b/packaging/windows/install-policy.wxi new file mode 100644 index 0000000000..a0cde6e26a --- /dev/null +++ b/packaging/windows/install-policy.wxi @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packaging/windows/installer-ui.wxs b/packaging/windows/installer-ui.wxs index a437c2a8c7..7bea8562f8 100644 --- a/packaging/windows/installer-ui.wxs +++ b/packaging/windows/installer-ui.wxs @@ -1,6 +1,4 @@ - @@ -10,13 +8,14 @@ - - - - + + + + + + + + @@ -26,14 +25,22 @@ - - + + - + + + + + + + + + + @@ -66,8 +73,7 @@ - + @@ -75,8 +81,7 @@ - + @@ -84,8 +89,7 @@ - + @@ -96,8 +100,7 @@ continues; Cancel ends setup. --> - + diff --git a/packaging/windows/package.ps1 b/packaging/windows/package.ps1 index dccc21f559..e9b9e4432b 100644 --- a/packaging/windows/package.ps1 +++ b/packaging/windows/package.ps1 @@ -154,6 +154,8 @@ Invoke-Native 'test-msi' { & (Get-Process -Id $PID).Path -NoProfile -File (Join-Path $PSScriptRoot 'test-msi.ps1') $Msi ` -CraftFontLicences $HasCraftFontLicences -ExpectedFontNoticesDirectory $Docs } +# Check the compiled scope, path and registration contract before signing. +& (Join-Path $PSScriptRoot 'check-install-policy.ps1') -MsiPath $Msi # wix writes its debug symbols (.wixpdb) next to the MSI; keep them out of the release assets. Remove-Item -Force -ErrorAction SilentlyContinue ([IO.Path]::ChangeExtension($Msi, '.wixpdb')) & (Join-Path $PSScriptRoot 'sign.ps1') $Msi diff --git a/packaging/windows/pdfcraft.wxs b/packaging/windows/pdfcraft.wxs index 65e76f254b..44f6012ba0 100644 --- a/packaging/windows/pdfcraft.wxs +++ b/packaging/windows/pdfcraft.wxs @@ -1,37 +1,16 @@ - - - + + + - - + - - + + + + + + + + - + + + - + - - + + - - - - - - - - - - - + + + + + + + + + + + + - - + + - - - + + + - - - - - + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packaging/windows/test-install-policy.ps1 b/packaging/windows/test-install-policy.ps1 new file mode 100644 index 0000000000..d455d19d16 --- /dev/null +++ b/packaging/windows/test-install-policy.ps1 @@ -0,0 +1,54 @@ +<# +.SYNOPSIS + Build non-executable installer metadata fixtures; never install them. +#> +param( + [Parameter(Mandatory = $true)] [string] $OutputDirectory, + [ValidateSet('x64','x86','arm64')] [string[]] $Architectures = @('x64','x86','arm64') +) +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$root = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path +$policy = Get-Content -Raw -LiteralPath (Join-Path $PSScriptRoot 'install-policy.json') | ConvertFrom-Json +[void](New-Item -ItemType Directory -Force -Path $OutputDirectory) +$output = (Resolve-Path -LiteralPath $OutputDirectory).Path +Add-Type -AssemblyName System.Drawing +foreach ($arch in $Architectures) { + $stage = Join-Path $output $arch + [void](New-Item -ItemType Directory -Force -Path $stage) + foreach ($name in $policy.payloads) { + $destination = Join-Path $stage $name + [void](New-Item -ItemType Directory -Force -Path ([IO.Path]::GetDirectoryName($destination))) + Set-Content -LiteralPath $destination -Encoding Ascii -Value 'NON-EXECUTABLE MSI METADATA TEST FIXTURE' + } + foreach ($folder in @('docs','models','notices')) { + $path = Join-Path $stage $folder + [void](New-Item -ItemType Directory -Force -Path $path) + Set-Content -LiteralPath (Join-Path $path "$folder-fixture.txt") -Value 'Metadata test fixture, not a user file or model.' + } + foreach ($model in @('metadata-one.rten','metadata-two.rten')) { + Set-Content -LiteralPath (Join-Path (Join-Path $stage 'models') $model) -Encoding Ascii -Value 'NON-EXECUTABLE MODEL METADATA FIXTURE' + } + foreach ($spec in @(@('banner.bmp',493,58),@('dialog.bmp',493,312))) { + $bitmap = [Drawing.Bitmap]::new($spec[1],$spec[2],[Drawing.Imaging.PixelFormat]::Format24bppRgb) + try { $bitmap.Save((Join-Path $stage $spec[0]),[Drawing.Imaging.ImageFormat]::Bmp) } + finally { $bitmap.Dispose() } + } + Set-Content -LiteralPath (Join-Path $stage 'licence.rtf') -Encoding Ascii -Value '{\rtf1 Metadata test fixture.}' + $sources = @($policy.sources | ForEach-Object { Join-Path $PSScriptRoot $_ }) + $msi = Join-Path $stage "$($policy.id)-NOT-INSTALLABLE.msi" + $extensions = @($policy.extensions | ForEach-Object { '-ext'; "WixToolset.$_.wixext" }) + & wix build @sources -arch $arch @extensions ` + -culture en-US -d 'Version=0.0.1' -d 'DisplayVersion=0.0.1-metadata-test' ` + -d "BinDir=$stage" -d "IconPath=$(Join-Path $root $policy.icon)" ` + -d "DocsDir=$(Join-Path $stage 'docs')" -d "ModelsDir=$(Join-Path $stage 'models')" ` + -d "AppNoticesDir=$(Join-Path $stage 'notices')" -d 'HasCraftFontLicences=1' -d 'HasCli=1' ` + -d "BannerBmp=$(Join-Path $stage 'banner.bmp')" -d "DialogBmp=$(Join-Path $stage 'dialog.bmp')" ` + -d "UiBannerBmp=$(Join-Path $stage 'banner.bmp')" -d "UiDialogBmp=$(Join-Path $stage 'dialog.bmp')" ` + -d "LicenseRtf=$(Join-Path $stage 'licence.rtf')" -o $msi @($policy.localization | ForEach-Object { '-loc'; Join-Path $PSScriptRoot $_ }) + if ($LASTEXITCODE -ne 0) { throw "WiX validation failed for $arch (exit $LASTEXITCODE)." } + $shell = (Get-Process -Id $PID).Path + & $shell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot 'check-install-policy.ps1') -MsiPath $msi + if ($LASTEXITCODE -ne 0) { throw "Compiled installer policy failed for $arch (exit $LASTEXITCODE)." } + Write-Output "PASS $arch metadata-only package. This is not an executable installer." +} diff --git a/packaging/windows/test-msi.ps1 b/packaging/windows/test-msi.ps1 index 24a4c6d75a..5dc4152d8f 100644 --- a/packaging/windows/test-msi.ps1 +++ b/packaging/windows/test-msi.ps1 @@ -38,41 +38,11 @@ function Assert-NoRow([string] $Sql, [string] $What) { } finally { [void] $view.Close() } } -# Test the compiled condition with Windows Installer's evaluator, in a restricted session that -# cannot change machine state. Normal installs/repairs work, per-user overrides fail, and removal -# of an older incorrectly scoped installation remains possible (#305). -$scopeMessage = 'PdfCraft must be installed for all users. Run setup with administrator privileges and ALLUSERS=1; per-user installation is not supported.' -$scopeCondition = Read-Row ('SELECT `Condition` FROM `LaunchCondition` WHERE `Description` = ''' + $scopeMessage + '''') 1 +# The shared compiled contract covers both scopes, duplicate guards and safe maintenance. +# Keep it in a separate process because Windows Installer permits one session per process. +& (Get-Process -Id $PID).Path -NoProfile -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot 'check-install-policy.ps1') -MsiPath $Path +if ($LASTEXITCODE -ne 0) { throw 'Dual-scope installer contract failed.' } $Installer.UILevel = 2 -$session = $null -try { - $session = $Installer.OpenPackage((Resolve-Path -LiteralPath $Path).Path, 1) - foreach ($case in @( - @('machine install', '1', '', '', '', 1), - @('machine repair', '1', '', '1', '', 1), - @('forced per-user install', '2', '1', '', '', 0), - @('empty ALLUSERS', '', '', '', '', 0), - @('resolved per-user install', '', '1', '', '', 0), - @('forced per-user repair', '2', '1', '1', '', 0), - @('machine uninstall', '1', '', '1', 'ALL', 1), - @('legacy per-user uninstall', '', '1', '1', 'ALL', 1))) { - $session.Property('ALLUSERS') = $case[1] - $session.Property('MSIINSTALLPERUSER') = $case[2] - $session.Property('Installed') = $case[3] - $session.Property('REMOVE') = $case[4] - Assert-Equal ($session.EvaluateCondition($scopeCondition[0])) $case[5] $case[0] - } -} finally { - if ($session) { [void] [Runtime.InteropServices.Marshal]::FinalReleaseComObject($session) } -} -foreach ($sequence in @('InstallUISequence', 'InstallExecuteSequence')) { - $launch = Read-Row ('SELECT `Condition`, `Sequence` FROM `' + $sequence + '` WHERE `Action` = ''LaunchConditions''') 2 - Assert-Equal $launch[0] '' "$sequence launch conditions are unconditional" - $cost = Read-Row ('SELECT `Sequence` FROM `' + $sequence + '` WHERE `Action` = ''CostInitialize''') 1 - if ([int] $launch[1] -le 0 -or [int] $launch[1] -ge [int] $cost[0]) { - throw "$sequence must reject per-user overrides before costing" - } -} $manufacturer = Read-Row 'SELECT `Value` FROM `Property` WHERE `Property` = ''Manufacturer''' 1 Assert-Equal $manufacturer[0] 'Learning Machines LLC' 'MSI manufacturer' @@ -173,7 +143,7 @@ Assert-Equal $checked[0] '1' 'Welcome checkbox value' $app = Read-Row 'SELECT `KeyPath` FROM `Component` WHERE `Component` = ''PdfcraftApp''' 1 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' +Assert-Equal $scope[0] '2' 'Dual-scope shortcut package' # 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 @@ -181,12 +151,22 @@ Assert-Equal $scope[0] '1' 'Per-machine shortcut scope' $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" + foreach ($context in @(@('M', '2'), @('U', '1'))) { + $cache = '' + $loaders = $Database.OpenView('SELECT `Action`, `Target` FROM `CustomAction` WHERE `Source` = ''' + $Found + '''') + try { + [void]$loaders.Execute() + while ($row = $loaders.Fetch()) { + if ($row.StringData(1) -match ('^CraftLoadOption[0-9]+' + $context[0] + '$')) { $cache = $row.StringData(2).Trim('[]') } + } + } finally { [void]$loaders.Close() } + if (-not $cache) { throw "$What has no context-specific loader." } + $search = Read-Row ('SELECT `Root`, `Key`, `Name`, `Type` FROM `RegLocator`, `AppSearch` WHERE `AppSearch`.`Signature_` = `RegLocator`.`Signature_` AND `AppSearch`.`Property` = ''' + $cache + '''') 4 + Assert-Equal $search[0] $context[1] "$What context $($context[0]) search root" + Assert-Equal $search[1] $Key "$What search key" + Assert-Equal $search[2] '' "$What search reads the default value" + 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" @@ -213,7 +193,7 @@ foreach ($ext in @('png', 'jpg', 'jpeg', 'tif', 'tiff', 'gif', 'bmp', 'jp2', 'j2 $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] 'PdfcraftImageContextMenu' "$ext context menu component" - Assert-Equal $menu[2] '2' "$ext context menu HKLM root" + Assert-Equal $menu[2] '-1' "$ext context menu context-sensitive root" $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" Assert-Equal $command[1] 'PdfcraftImageContextMenu' "$ext context menu command component" @@ -246,7 +226,7 @@ $pdfKey = 'Software\Classes\SystemFileAssociations\.pdf\shell\PdfCraft.Edit' $pdfMenu = Read-Row ('SELECT `Registry`, `Value`, `Component_`, `Root` FROM `Registry` WHERE `Key` = ''' + $pdfKey + ''' AND `Name` IS NULL') 4 Assert-Equal $pdfMenu[1] 'Edit with PdfCraft' 'PDF context menu label' Assert-Equal $pdfMenu[2] 'PdfcraftPdfContextMenu' 'PDF context menu component' -Assert-Equal $pdfMenu[3] '2' 'PDF context menu HKLM root' +Assert-Equal $pdfMenu[3] '-1' 'PDF context menu context-sensitive root' foreach ($value in @(@('Icon', '"[#PdfcraftExe]",0'), @('MultiSelectModel', 'Document'))) { $row = Read-Row ('SELECT `Value`, `Component_` FROM `Registry` WHERE `Key` = ''' + $pdfKey + ''' AND `Name` = ''' + $value[0] + '''') 2 Assert-Equal $row[0] $value[1] "PDF context menu $($value[0])" @@ -288,15 +268,18 @@ $modelsDir = Read-Row 'SELECT `Directory_Parent`, `DefaultDir` FROM `Directory` Assert-Equal $modelsDir[0] 'INSTALLFOLDER' 'OCR models folder parent' # DefaultDir is `models`, or `SHORT|models` with a generated 8.3 name. if ($modelsDir[1] -notmatch '(^|\|)models$') { throw "OCR models folder name: '$($modelsDir[1])'" } -$modelFiles = 0 +$modelFiles = @() $view = $Database.OpenView('SELECT `File`.`FileName` FROM `File`, `Component` WHERE `File`.`Component_` = `Component`.`Component` AND `Component`.`Directory_` = ''ModelsFolder''') try { [void] $view.Execute() - while ($record = $view.Fetch()) { if ($record.StringData(1) -match '\.rten$') { $modelFiles++ } } + # FileName can contain a generated 8.3 name before the long name: SHORT|long. + while ($record = $view.Fetch()) { $modelFiles += ($record.StringData(1) -split '\|')[-1] } } finally { [void] $view.Close() } -if ($modelFiles -lt 2) { throw "expected the OCR models (*.rten) in models\, found $modelFiles" } +foreach ($model in @('text-detection.onnx', 'text-recognition.onnx')) { + Assert-Equal (@($modelFiles | Where-Object { $_ -ceq $model }).Count) 1 "OCR model $model in models\" +} $rm = Read-Row 'SELECT `Dialog` FROM `Dialog` WHERE `Dialog` = ''MsiRMFilesInUse''' 1 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 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' +Write-Output 'ok MSI: dual-scope guards, 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 5e1c4a7e75..9742028dcb 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -22,7 +22,7 @@ milestone = "M2" status = "shipped" commands = ["file.word_count"] tools = ["text_word_count"] -evidence = ["crates/engine/src/word_count.rs::word_count_filters_are_independent_and_do_not_change_normal_extraction", "crates/automation/tests/it/automation.rs::word_count_reads_selected_pages_and_unsaved_edits_without_mutating", "crates/ui-egui/src/word_count_ui.rs::word_count_dialog_counts_changes_pages_and_closes"] +evidence = ["crates/engine/src/word_count.rs::word_count_filters_are_independent_and_do_not_change_normal_extraction", "crates/automation/tests/it/automation.rs::word_count_reads_selected_pages_and_unsaved_edits_without_mutating", "crates/ui-egui/src/dialogs/word_count_ui.rs::word_count_dialog_counts_changes_pages_and_closes"] notes = "Unicode words and characters for the current snapshot; all or current page in GUI, selected pages headlessly. Desktop background work with cancellation. Optional semantic table/code/Figure and invisible-text exclusions; no OCR or visual layout classification. Unsupported structure remains counted and is reported. Mixed ActualText exclusions fail explicitly. See docs/word-count.md." [[feature]] @@ -34,7 +34,7 @@ milestone = "M1" status = "shipped" commands = ["file.open"] tools = ["doc_open"] -evidence = ["crates/automation/tests/it/automation.rs::open_inspect_render_and_find", "crates/ui-egui/tests/it/ui.rs::opening_a_pdf_shows_comments_and_bookmarks", "crates/ui-egui/tests/it/ui.rs::files_and_quit_from_the_operating_system", "crates/ui-egui/src/open_worker_tests.rs::a_chosen_tool_runs_on_its_file_only_after_native_preparation_commits", "crates/ui-egui/tests/it/editing.rs::opening_the_same_path_again_focuses_its_tab", "crates/ui-egui/tests/it/editing.rs::opening_a_file_whose_copy_vanished_from_disk_focuses_its_tab"] +evidence = ["crates/automation/tests/it/automation.rs::open_inspect_render_and_find", "crates/ui-egui/tests/it/ui.rs::opening_a_pdf_shows_comments_and_bookmarks", "crates/ui-egui/tests/it/ui.rs::files_and_quit_from_the_operating_system", "crates/ui-egui/src/platform/open_worker/tests.rs::a_chosen_tool_runs_on_its_file_only_after_native_preparation_commits", "crates/ui-egui/tests/it/editing.rs::opening_the_same_path_again_focuses_its_tab", "crates/ui-egui/tests/it/editing.rs::opening_a_file_whose_copy_vanished_from_disk_focuses_its_tab"] notes = "File ▸ Open, the command line (Linux and Windows file associations), the Windows MSI's \"Edit with PdfCraft\" entry on PDF files' File Explorer right-click menu (#682; several selected PDFs open as tabs of one window), and on macOS Finder, Open With and the Dock (Apple events; the bundle declares PDF as a document type); an already-open file focuses its tab instead of opening a second copy (#1620). Not yet: a first-level Windows 11 menu entry (needs package identity) or Combine from the file manager (#585)" [[feature]] @@ -46,7 +46,7 @@ milestone = "M1" status = "shipped" commands = ["file.open"] tools = ["doc_open"] -evidence = ["crates/cos/src/document.rs::damaged_xref_is_reconstructed", "crates/cos/src/parser.rs::indirect_stream_with_wrong_length_recovers", "crates/cos/src/document.rs::self_referential_length_does_not_overflow", "crates/engine/src/tests.rs::a_repaired_file_is_rewritten_by_save_even_without_edits", "crates/automation/tests/it/automation.rs::saving_a_repaired_file_puts_the_repair_on_disk", "crates/engine/src/tests.rs::a_signed_repaired_file_keeps_its_bytes_when_saved_without_edits", "crates/cos/src/document.rs::a_table_numbered_from_1_that_begins_with_object_0_is_read_from_0"] +evidence = ["crates/cos/src/document/tests.rs::damaged_xref_is_reconstructed", "crates/cos/src/parser.rs::indirect_stream_with_wrong_length_recovers", "crates/cos/src/document/tests.rs::self_referential_length_does_not_overflow", "crates/engine/src/tests.rs::a_repaired_file_is_rewritten_by_save_even_without_edits", "crates/automation/tests/it/automation.rs::saving_a_repaired_file_puts_the_repair_on_disk", "crates/engine/src/tests.rs::a_signed_repaired_file_keeps_its_bytes_when_saved_without_edits", "crates/cos/src/document/tests.rs::a_table_numbered_from_1_that_begins_with_object_0_is_read_from_0"] notes = "Save and doc_save write a reconstructed file in full even when nothing was edited, so the repair reaches the disk and the saved file opens without one (#1937); a signed one keeps its bytes, so its signatures stay valid. A repair does not mark the document as having unsaved changes, so closing it does not offer to save the repaired file. A classic table numbered 1 n that begins with object 0's free entry is read from 0 (#2676) and logged." [[feature]] @@ -58,7 +58,7 @@ milestone = "M1" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/it/ui.rs::garbage_input_is_rejected_without_panicking", "crates/cos/src/parser.rs::garbage_never_panics", "crates/render/src/raster.rs::missing_page_and_garbage_input_fail_gracefully"] +evidence = ["crates/ui-egui/tests/it/ui.rs::garbage_input_is_rejected_without_panicking", "crates/cos/src/parser.rs::garbage_never_panics", "crates/render/src/raster/tests.rs::missing_page_and_garbage_input_fail_gracefully"] notes = "fuzzed nightly (M1.10)" [[feature]] @@ -83,7 +83,7 @@ status = "shipped" commands = [] tools = [] evidence = ["crates/cos/tests/it/writer.rs::incremental_saves_stack_on_an_xref_stream_file", "crates/cos/tests/it/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers"] -notes = "" +notes = "Reads classic tables, cross-reference streams, hybrid /XRefStm files and objects inside object streams (crates/cos/src/document.rs), and rebuilds a damaged table with a note in the repair log. The cited tests read files our own writer produced (plus a hand-built looping page tree); no third-party xref-stream sample is cited here." [[feature]] id = "core.hybrid-xref" @@ -94,7 +94,7 @@ milestone = "M1" status = "shipped" commands = [] tools = ["doc_open"] -evidence = ["crates/cos/src/document.rs::hybrid_reference_files_read_the_xref_stream_too"] +evidence = ["crates/cos/src/document/tests.rs::hybrid_reference_files_read_the_xref_stream_too"] notes = "/XRefStm entries supplement the classic table and replace its free entries for objects in object streams; each section is merged as a unit so older revisions only fill gaps" [[feature]] @@ -106,7 +106,7 @@ milestone = "M1" status = "shipped" commands = ["file.properties"] tools = ["doc_revisions"] -evidence = ["crates/cos/src/document.rs::looping_revision_chains_stop", "crates/cos/src/document.rs::revision_ends_split_incremental_updates"] +evidence = ["crates/cos/src/document/tests.rs::looping_revision_chains_stop", "crates/cos/src/document/tests.rs::revision_ends_split_incremental_updates"] notes = "/Prev chains followed newest first with a visited set (loops stop and are logged); revisions listed and opened (doc_revisions, doc_open_revision)" [[feature]] @@ -130,8 +130,8 @@ milestone = "M1" status = "partial" commands = [] tools = [] -evidence = [] -notes = "decoded through the hayro bootstrap for display; own codecs are M1.1/M1.2" +evidence = ["crates/filters/src/ccitt.rs::decoding_gives_the_rows_back", "crates/filters/src/ccitt.rs::group_3_rows_on_bytes_read_by_the_codes_in_the_data", "crates/cos/src/document/tests.rs::fax_images_decode_with_their_parameters", "crates/filters/src/jbig2/decode.rs::both_codings_decode_to_their_samples", "crates/filters/src/jbig2/decode.rs::what_it_cannot_decode_is_an_error", "crates/cos/src/document/tests.rs::jbig2_images_decode_with_their_globals", "crates/cos/src/document/tests.rs::shared_jbig2_globals_are_read_once"] +notes = "decoded through the hayro bootstrap for display; own codecs are M1.1/M1.2. CCITT fax decoding is in filters (decode_ccitt, through hayro-ccitt), which Document::decoded_image uses for Export ▸ Images, the optimizer and redaction; so is JBIG2 decoding (decode_jbig2, through hayro-jbig2, the page bounded before it is decoded), which it uses with the image's /JBIG2Globals (within 32 MiB) for Export ▸ Images, Copy Image, redaction and the optimizer. Byte-aligned Group 3 is read by its end-of-line codes when the data starts with one and on bytes otherwise; still limited: data with codes but none before the first row (not as T.4 sends them) is read on bytes, as the renderer draws it, and at /K 0 may come out as other rows, which the optimizer then writes back. The bootstrap renderer draws byte-aligned Group 3 whose fill ends each end-of-line code on a byte as other rows (black box, at every /K), which decode_ccitt reads right; redaction removes Group 3 on bytes whole" [[feature]] id = "core.decompression-limits" @@ -143,7 +143,7 @@ status = "shipped" commands = [] tools = [] evidence = ["crates/filters/src/flate.rs::bomb_is_bounded", "crates/filters/src/lib.rs::limit_applies_to_intermediate_buffers"] -notes = "" +notes = "Every filter decode is capped (1 GiB per stream, MAX_DECODED), and a DecodeBudget caps one page walk at 256 MiB decoded and 50,000 form entries, so a repeated bomb is inflated once. The cited tests cover Flate and chained ASCII filters only; image codecs (DCT, JPX, CCITT) and the budget itself are not covered by the cited tests, and the limits are fixed, not user settings." [[feature]] id = "core.open-rc4" @@ -155,7 +155,7 @@ status = "shipped" commands = ["file.open"] tools = ["doc_open"] evidence = ["crates/crypt/src/tests.rs::rc4_reference_vectors", "crates/cos/tests/it/encryption.rs::every_algorithm_round_trips_and_hides_the_content"] -notes = "" +notes = "Opens R2 (40-bit) and R3/R4 (128-bit) RC4 files with the user or owner password, asks for one when needed, and also tries a UTF-8 form of the password for characters PDFDocEncoding lacks. Tests cover RC4 reference vectors and files our own writer encrypts (checked against hayro and qpdf); no RC4 file made by another producer is cited, and Acrobat behaviour was not observed." [[feature]] id = "core.open-aes" @@ -167,7 +167,7 @@ status = "shipped" commands = ["file.open"] tools = ["doc_open"] evidence = ["crates/crypt/src/tests.rs::aes_cbc_nist_sp800_38a_vectors", "crates/cos/tests/it/encryption.rs::every_algorithm_round_trips_and_hides_the_content"] -notes = "" +notes = "Opens AES-128 (R4, AESV2) and AES-256 (R6, AESV3) files; the code also accepts R5, which the cited tests do not generate. Tests use NIST CBC vectors and files our own writer encrypts (checked against hayro and qpdf), not files from other producers; docs/file-format-parity.md says Acrobat DC opened our AES-256 output by hand, with no automated interop suite." [[feature]] id = "core.password-prompt" @@ -178,7 +178,7 @@ milestone = "M1" status = "shipped" commands = ["file.open"] tools = ["doc_open"] -evidence = ["crates/ui-egui/tests/it/editing.rs::password_prompt_opens_and_security_tab_reports_the_details", "crates/crypt/src/tests.rs::empty_user_password_opens_without_asking", "crates/ui-egui/src/open_worker_tests.rs::a_chosen_tool_survives_wrong_password_retry_and_runs_after_unlock", "crates/ui-egui/src/open_worker_tests.rs::startup_intake_waits_for_local_authentication_and_keeps_its_own_background_password_prompt", "crates/ui-egui/tests/it/pickers.rs::the_open_password_can_be_typed_through_an_input_method_however_the_file_is_opened"] +evidence = ["crates/ui-egui/tests/it/editing.rs::password_prompt_opens_and_security_tab_reports_the_details", "crates/crypt/src/tests.rs::empty_user_password_opens_without_asking", "crates/ui-egui/src/platform/open_worker/tests.rs::a_chosen_tool_survives_wrong_password_retry_and_runs_after_unlock", "crates/ui-egui/src/platform/open_worker/tests.rs::startup_intake_waits_for_local_authentication_and_keeps_its_own_background_password_prompt", "crates/ui-egui/tests/it/pickers.rs::the_open_password_can_be_typed_through_an_input_method_however_the_file_is_opened"] notes = "the field takes typing through the platform input method, which typed keys go through on Linux, however the file was opened (#2818); checked with synthetic input-method events, not on a Linux desktop" [[feature]] @@ -191,7 +191,7 @@ status = "shipped" commands = [] tools = [] evidence = ["crates/crypt/src/tests.rs::r6_passwords_are_saslprepped"] -notes = "" +notes = "R5/R6 passwords go through SASLprep (RFC 4013, NFKC, soft hyphens dropped) as UTF-8 capped at 127 bytes, using the raw password if SASLprep refuses it; R2-R4 use PDFDocEncoding padded or cut to 32 bytes. The cited test checks only the R6 case; the R2-R4 rules are covered by effective_password tests that are not cited here, and no other application's output was compared." [[feature]] id = "core.owner-password-unlock" @@ -215,7 +215,7 @@ status = "shipped" commands = [] tools = [] evidence = ["crates/ui-egui/tests/it/editing.rs::restricted_documents_show_a_notice_and_block_page_changes", "crates/crypt/src/tests.rs::permission_bits_follow_table_22", "crates/crypt/src/tests.rs::perms_limits_what_an_edited_p_grants", "crates/engine/src/tests.rs::combining_protected_files_is_refused_clearly", "crates/engine/src/tests.rs::print_restricted_documents_disable_print_unless_opened_by_the_owner", "crates/ui-egui/tests/it/print.rs::print_restricted_documents_say_so_before_the_dialog", "crates/engine/src/tests.rs::convert_to_pdfa_needs_the_owners_rights_on_an_encrypted_document"] -notes = "" +notes = "Session gates print, modify, assemble, copy, annotate and fill-forms on the /P bits for non-owners, with an owner-password unlock, and a low-resolution-only print limit makes the print file pictures of at most 150 dpi. Reading for assistive technology is never blocked, and this is software enforcement only: other readers can ignore the bits, and Acrobat's own per-bit behaviour was not observed." [[feature]] id = "core.crypt-filters" @@ -227,7 +227,7 @@ status = "shipped" commands = [] tools = [] evidence = ["crates/crypt/src/tests.rs::identity_crypt_filters_leave_data_alone"] -notes = "" +notes = "Reads /CF, /StmF, /StrF and /EFF, so Identity strings or streams and embedded-file-only encryption work, and named crypt filters on streams are honoured. An unrecognised /CFM is treated as Identity (data left as is) and custom crypt-filter handlers are not supported; the cited test covers one embedded-file-only case built by our own writer." [[feature]] id = "core.attachment-only-encryption" @@ -251,7 +251,7 @@ status = "shipped" commands = [] tools = [] evidence = ["crates/cos/tests/it/encryption.rs::unencrypted_metadata_is_left_readable"] -notes = "" +notes = "With /EncryptMetadata false the XMP metadata stream stays readable in the saved file, and a Metadata stream is left alone on read when that flag says so (crates/cos/src/security.rs). Only AES-256 written by our own writer is cited; reading such a file from another producer and the R2-R4 variants are not covered by the cited test." [[feature]] id = "core.unsupported-handlers" @@ -263,7 +263,7 @@ status = "shipped" commands = [] tools = [] evidence = ["crates/crypt/src/tests.rs::unsupported_handlers_are_reported"] -notes = "" +notes = "A non-Standard /Filter (such as Adobe.PubSec certificate security) or a revision other than 2-6 stops opening with an unsupported security handler error. The cited test checks the crypt-level error only, not the dialog wording, and public-key (certificate) encryption itself is not supported." [[feature]] id = "core.open-public-key" @@ -286,7 +286,7 @@ milestone = "M1" status = "shipped" commands = ["file.save"] tools = ["doc_save"] -evidence = ["crates/platform/src/staging.rs::atomic_saves_keep_finder_tags_and_binary_extended_attributes", "crates/platform/src/staging.rs::a_growing_resource_fork_refuses_publication_and_keeps_original", "crates/automation/tests/it/automation.rs::saving_a_pdf_keeps_the_destinations_extended_attributes", "crates/automation/tests/it/automation.rs::a_metadata_preservation_error_keeps_the_pdf_and_unsaved_edit", "crates/ui-egui/tests/it/editing.rs::save_writes_an_incremental_update_and_clears_dirty", "crates/engine/src/tests.rs::save_is_incremental_stamps_mod_date_and_rebases", "crates/organize/src/tests.rs::successive_incremental_saves_stack_revisions", "crates/automation/src/lib.rs::staging_never_writes_through_a_file_planted_at_its_name", "crates/ui-egui/src/editing.rs::saving_never_writes_through_a_file_planted_at_the_staging_name"] +evidence = ["crates/platform/src/staging/tests.rs::atomic_saves_keep_finder_tags_and_binary_extended_attributes", "crates/platform/src/staging/tests.rs::a_growing_resource_fork_refuses_publication_and_keeps_original", "crates/automation/tests/it/automation.rs::saving_a_pdf_keeps_the_destinations_extended_attributes", "crates/automation/tests/it/automation.rs::a_metadata_preservation_error_keeps_the_pdf_and_unsaved_edit", "crates/ui-egui/tests/it/editing.rs::save_writes_an_incremental_update_and_clears_dirty", "crates/engine/src/tests.rs::save_is_incremental_stamps_mod_date_and_rebases", "crates/organize/src/tests.rs::successive_incremental_saves_stack_revisions", "crates/automation/src/tests.rs::staging_never_writes_through_a_file_planted_at_its_name", "crates/ui-egui/src/editing/tests.rs::saving_never_writes_through_a_file_planted_at_the_staging_name"] notes = "The atomic write stages to a new, randomly named file created with create_new, so a file or link planted at the staging path is never written through (desktop app, automation and CLI). On macOS, accessible destination xattrs (including Finder tags) are copied before flush/rename; errors, metadata limits or a changed post-read size refuse the save without replacing the file. System-managed provenance is excluded. Permissions, ACLs, hard-link identity and creation dates are not preserved by this change (#2658)." [[feature]] @@ -299,7 +299,7 @@ status = "shipped" commands = ["file.save_as"] tools = ["doc_save"] evidence = ["crates/organize/src/tests.rs::full_save_drops_unreachable_objects", "crates/engine/src/tests.rs::full_save_is_readable_and_smaller_after_deletes"] -notes = "" +notes = "Save As to another path rewrites the file in full and drops unreachable objects, while Save to the same path stays incremental and a signed document is never rewritten; doc_save takes a full flag. The cited tests check that deleted pages' objects go and the file reopens smaller; unused fonts or images are only dropped when nothing references them, with no further optimisation here (see the optimize.* entries)." [[feature]] id = "core.object-stream-compression" @@ -311,7 +311,7 @@ status = "shipped" commands = ["file.save_as"] tools = ["doc_save"] evidence = ["crates/cos/tests/it/writer.rs::full_save_packs_objects_into_object_streams", "crates/cos/tests/it/writer.rs::qpdf_accepts_object_stream_output", "crates/cos/tests/it/writer.rs::objects_referenced_by_the_encrypt_dictionary_stay_outside_object_streams"] -notes = "" +notes = "A full save packs non-stream objects into object streams (100 per stream, 8 MiB batches) behind an xref stream, keeping the catalog and encryption objects outside them. It is on by default in SaveOptions (the engine's save paths never turn it off) and is not a user setting; qpdf_accepts_object_stream_output uses qpdf as an outside check, and old-reader compatibility was not tested." [[feature]] id = "core.encrypted-save" @@ -323,7 +323,7 @@ status = "shipped" commands = ["file.save"] tools = ["doc_save"] evidence = ["crates/cos/tests/it/encryption.rs::incremental_edits_of_encrypted_documents_stay_encrypted", "crates/engine/src/tests.rs::encrypted_documents_open_edit_and_save_encrypted"] -notes = "" +notes = "An incremental save appends to the encrypted file and encrypts only the new objects with the existing handler; a full rewrite re-encrypts all content subject to the encryption policy (metadata stays unencrypted when /EncryptMetadata is false), and changing or removing security forces a full rewrite. Cited tests cover all four algorithms through our own writer and a hayro reopen; docs/file-format-parity.md says Acrobat DC opened AES-256 and RC4-128 output by hand." [[feature]] id = "core.copy-on-write-fidelity" @@ -334,8 +334,8 @@ milestone = "M1" status = "shipped" commands = [] tools = [] -evidence = ["crates/cos/src/document.rs::reads_classic_revision_and_edits_are_copy_on_write"] -notes = "" +evidence = ["crates/cos/src/document/tests.rs::reads_classic_revision_and_edits_are_copy_on_write"] +notes = "Edits live in an overlay over the original bytes, so a clone is an independent snapshot and an incremental save appends without changing the original bytes (checked by the incremental-save tests in crates/cos and crates/engine). A full rewrite re-serialises objects, so byte identity holds for incremental saves only, and the cited test is a single small in-memory case." [[feature]] id = "core.write-validity" @@ -346,7 +346,7 @@ milestone = "M1" status = "shipped" commands = [] tools = [] -evidence = ["crates/cos/tests/it/encryption.rs::qpdf_agrees", "crates/cos/tests/it/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/it/edit.rs::editing_in_place_leaves_a_valid_file_and_no_staging_file", "crates/automation/tests/it/automation.rs::a_root_less_write_output_is_atomic_too"] +evidence = ["crates/cos/tests/it/encryption.rs::qpdf_agrees", "crates/cos/tests/it/writer.rs::qpdf_accepts_object_stream_output", "crates/platform/src/staging/tests.rs::an_atomic_write_replaces_the_target_only_when_complete", "apps/pdfcraft-cli/tests/it/edit.rs::editing_in_place_leaves_a_valid_file_and_no_staging_file", "crates/automation/tests/it/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]] @@ -382,7 +382,7 @@ milestone = "M1" status = "shipped" commands = ["file.properties"] tools = ["doc_revisions"] -evidence = ["crates/cos/src/document.rs::revision_ends_split_incremental_updates", "crates/automation/tests/it/automation.rs::digital_ids_signing_and_validation_through_tools"] +evidence = ["crates/cos/src/document/tests.rs::revision_ends_split_incremental_updates", "crates/automation/tests/it/automation.rs::digital_ids_signing_and_validation_through_tools"] notes = "revision ends from the xref chain (linearized first-page sections fold into their revision); listed in Document Properties ▸ Advanced and by doc_revisions with the signatures covering each" [[feature]] @@ -479,7 +479,7 @@ status = "shipped" commands = ["edit.undo", "edit.redo"] tools = ["edit_undo", "edit_redo"] evidence = ["crates/engine/src/tests.rs::undo_and_redo_restore_exact_states", "crates/ui-egui/tests/it/editing.rs::organize_delete_then_undo_and_redo_with_keys"] -notes = "" +notes = "Every edit goes through the session's snapshot history (100 steps, MAX_UNDO) with redo cleared by a new edit, and undo back to the opened bytes restores them exactly. Missing: the history is per open document and is not saved with the file, and the depth limit is fixed; the cited UI test covers organize delete only." [[feature]] id = "core.undo-labels" @@ -491,7 +491,7 @@ status = "shipped" commands = ["edit.undo", "edit.redo"] tools = [] evidence = ["crates/ui-egui/tests/it/editing.rs::edit_menu_names_the_step_to_undo", "crates/engine/src/commands.rs::enablement_follows_the_document_state"] -notes = "" +notes = "The Edit menu shows Undo and Redo with the name of the step (Delete page, Rotate page and so on) taken from the history labels, and the command is disabled when nothing is left. The cited UI test checks one label (organize), not every edit's wording or its translations." [[feature]] id = "core.undo-after-save" @@ -503,7 +503,7 @@ status = "shipped" commands = ["edit.undo"] tools = ["edit_undo"] evidence = ["crates/engine/src/tests.rs::undo_still_works_after_save"] -notes = "" +notes = "Saving rebases the history rather than clearing it, so undo still steps back past a save, and undoing to the saved file clears the dirty flag while undoing past it sets it again. The cited engine test covers an incremental save only, not Save As or encrypted files." [[feature]] id = "core.atomic-batch-edits" @@ -515,7 +515,7 @@ status = "shipped" commands = [] tools = [] evidence = ["crates/engine/src/tests.rs::batch_is_one_undo_step_and_all_or_nothing", "crates/engine/src/tests.rs::failed_edit_changes_nothing"] -notes = "" +notes = "Edit::Batch applies several edits as one undo step under one label, and if any member fails the document is left as it was; a single failing edit also changes nothing. Missing: no cited test for batches inside signed or encrypted documents, and the cited cases are small." [[feature]] id = "core.history-panel" @@ -539,7 +539,7 @@ status = "shipped" commands = [] tools = [] evidence = ["crates/engine/src/tests.rs::autosave_snapshots_only_changed_documents", "crates/ui-egui/tests/it/recovery.rs::saving_or_closing_clears_the_entry_and_nothing_is_written_for_clean_documents"] -notes = "" +notes = "Every 60 seconds the app writes a snapshot of each changed document to a recovery folder (the desktop app turns it on in apps/pdfcraft/src/main.rs), offers recovery after a crash, and removes the entry on save or close. Missing: it is a recovery copy, never the user's own file, and the browser build was not checked." [[feature]] id = "core.crash-recovery" @@ -563,7 +563,7 @@ status = "shipped" commands = [] tools = [] evidence = ["crates/ui-egui/tests/it/recovery.rs::encrypted_documents_are_autosaved_encrypted_and_recovered_with_the_password"] -notes = "" +notes = "Snapshots of encrypted documents are written with the document's encryption policy, so document content stays encrypted (metadata stays unencrypted when /EncryptMetadata is false), and recovery asks for the password. The cited UI test covers one AES case; whether other methods and owner-only restricted files recover the same way is not tested." [[feature]] id = "core.web-recovery" @@ -599,7 +599,7 @@ status = "shipped" commands = [] tools = [] evidence = ["crates/ui-egui/tests/it/editing.rs::organize_delete_then_undo_and_redo_with_keys", "crates/engine/src/tests.rs::edits_update_view_data_and_mark_dirty"] -notes = "" +notes = "The tab strip reads each document's dirty flag (crates/ui-egui/src/chrome.rs) and the flag follows edits, undo to the saved state and saves. The cited tests check the flag, not the drawn marker; the marker itself was not checked by a test or screenshot for this note." [[feature]] id = "core.close-document" @@ -611,7 +611,7 @@ status = "shipped" commands = ["file.close"] tools = ["doc_close"] evidence = ["crates/ui-egui/tests/it/editing.rs::clean_tabs_close_without_asking"] -notes = "" +notes = "File Close and Cmd/Ctrl+W close a clean tab at once, and a document with unsaved changes asks to save first; doc_close fails unless discard_changes is set. The cited test covers only a clean tab; the prompt is covered by other tests (quitting_with_unsaved_changes_asks_for_each_document) not cited here." [[feature]] id = "core.close-all" @@ -694,7 +694,7 @@ milestone = "M3" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/it/editing.rs::the_last_session_reopens_its_files_pages_and_active_tab", "crates/ui-egui/tests/it/editing.rs::quitting_with_unsaved_changes_still_remembers_every_file", "crates/ui-egui/tests/it/editing.rs::the_last_session_is_off_by_default_and_skips_missing_and_duplicate_files", "crates/ui-egui/src/last_session.rs::malformed_sessions_are_skipped_and_capped"] +evidence = ["crates/ui-egui/tests/it/editing.rs::the_last_session_reopens_its_files_pages_and_active_tab", "crates/ui-egui/tests/it/editing.rs::quitting_with_unsaved_changes_still_remembers_every_file", "crates/ui-egui/tests/it/editing.rs::the_last_session_is_off_by_default_and_skips_missing_and_duplicate_files", "crates/ui-egui/src/persist/last_session.rs::malformed_sessions_are_skipped_and_capped"] notes = "Desktop, off by default (Preferences ▸ Documents and view, #442): the files open when PdfCraft last closed reopen at the page each showed, with the same tab in front; files named on the command line open after them, in front; files that are gone, and documents the Recovery dialog offers, are skipped. Quitting with unsaved changes still remembers every tab. Zoom and page display come back through core.restore-last-view (#1767); not yet the scroll position within the page (#418); only the first encrypted file asks for its password (later ones stay closed), and a file opened after it dismisses that prompt; a save that fails partway through a quit can leave the remembered list from that quit; never-saved documents and the web build have no paths to reopen." [[feature]] @@ -734,7 +734,7 @@ milestone = "M2" status = "partial" commands = [] tools = ["page_render"] -evidence = ["crates/render/src/raster.rs::renders_a_blue_rectangle", "crates/automation/tests/it/automation.rs::open_inspect_render_and_find", "crates/render/src/raster.rs::user_unit_scales_the_page_and_everything_on_it"] +evidence = ["crates/render/src/raster/tests.rs::renders_a_blue_rectangle", "crates/automation/tests/it/automation.rs::open_inspect_render_and_find", "crates/render/src/raster/tests.rs::user_unit_scales_the_page_and_everything_on_it"] notes = "hayro bootstrap renderer; own DisplayList devices in M2.6. Pages with /UserUnit are shown at their size in points, as Acrobat reads the value" [[feature]] @@ -746,7 +746,7 @@ milestone = "M2" status = "partial" commands = [] tools = [] -evidence = ["crates/render/src/raster.rs::self_referencing_tiling_pattern_terminates", "crates/render/tests/it/tiling_cell_placement.rs::far_cells_of_a_fill_sit_at_whole_steps", "crates/render/tests/it/tiling_cell_placement.rs::far_cells_sit_at_whole_steps_under_a_turned_scaled_matrix", "crates/render/tests/it/tiling_cell_placement.rs::far_cells_of_a_stroke_sit_at_whole_steps"] +evidence = ["crates/render/src/raster/tests.rs::self_referencing_tiling_pattern_terminates", "crates/render/tests/it/tiling_cell_placement.rs::far_cells_of_a_fill_sit_at_whole_steps", "crates/render/tests/it/tiling_cell_placement.rs::far_cells_sit_at_whole_steps_under_a_turned_scaled_matrix", "crates/render/tests/it/tiling_cell_placement.rs::far_cells_of_a_stroke_sit_at_whole_steps"] notes = "via hayro bootstrap. Tiling pattern cells sit at whole multiples of /XStep and /YStep at any zoom, filled or stroked, under any pattern matrix. Missing: a cell is cut at its step, so content reaching past the step into the next cell is lost; a cell pixmap is capped at 3000 pixels a side, so very large steps draw at reduced resolution" [[feature]] @@ -758,7 +758,7 @@ milestone = "M2" status = "partial" commands = [] tools = [] -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", "crates/render/tests/it/luminosity_group_space.rs::black_ink_in_a_device_cmyk_group_masks_fully_and_no_ink_shows_fully"] +evidence = ["crates/render/src/raster/tests.rs::soft_mask_without_group_colour_space_is_applied", "crates/render/src/raster/tests.rs::a_multiply_highlight_inside_a_transparency_group_keeps_the_text", "crates/render/tests/it/luminosity_group_space.rs::black_ink_in_a_device_cmyk_group_masks_fully_and_no_ink_shows_fully"] notes = "via hayro bootstrap. A luminosity soft mask whose group is in DeviceCMYK reads its flat CMYK colours and backdrop as their gray, 1 - min(1, 0.3 c + 0.59 m + 0.11 y + k) (ISO 32000-2 §11.5.3), so 100% K masks fully; CMYK images, shadings and tiling patterns inside such a mask still go through the display profile. 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]] @@ -794,8 +794,8 @@ milestone = "M2" status = "partial" commands = [] tools = [] -evidence = ["crates/render/src/raster.rs::huge_cid_width_ranges_terminate", "crates/render/src/raster.rs::non_embedded_japanese_cid_fonts_draw_with_a_craft_fonts_face", "crates/render/src/raster.rs::mincho_substitute_covers_half_width_katakana", "crates/render/src/text.rs::identity_to_unicode_names_give_text", "crates/fonts/src/pdf.rs::identity_to_unicode_names_read_codes_as_unicode"] -notes = "CID-to-Unicode tables are M2.4; a non-embedded Adobe-Japan1 font is drawn in a craft-fonts Japanese face (Mincho or Gothic by name, else by the descendant font descriptor's Serif flag; bold Gothic for a Bold name or a `/FontWeight` of 600 or more; BIZ UDMincho for Mincho, which covers half-width katakana) when built with craft-fonts; non-embedded GB1, CNS1 and Korea1 fonts still draw nothing (no allowed face); a `/ToUnicode` given as the name `/Identity-H` or `/Identity-V` (WSCAD) reads codes as Unicode for selection, search and editing, without control characters" +evidence = ["crates/render/src/raster/tests.rs::huge_cid_width_ranges_terminate", "crates/render/src/raster/tests.rs::non_embedded_japanese_cid_fonts_draw_with_a_craft_fonts_face", "crates/render/src/raster/tests.rs::mincho_substitute_covers_half_width_katakana", "crates/render/src/raster/tests.rs::non_embedded_chinese_and_korean_cid_fonts_draw_with_an_installed_face", "crates/engine/src/tests.rs::installed_cjk_fonts_draw_in_the_viewer_but_not_in_an_export", "crates/render/src/text/tests.rs::identity_to_unicode_names_give_text", "crates/fonts/src/pdf/tests.rs::identity_to_unicode_names_read_codes_as_unicode", "crates/render/src/raster/tests.rs::a_soft_hyphen_a_substitute_lacks_draws_its_hyphen"] +notes = "CID-to-Unicode tables are M2.4; a non-embedded Adobe-Japan1 font is drawn in a craft-fonts Japanese face (Mincho or Gothic by name, else by the descendant font descriptor's Serif flag; bold Gothic for a Bold name or a `/FontWeight` of 600 or more; BIZ UDMincho for Mincho, which covers half-width katakana) when built with craft-fonts; the desktop viewer can fall back to an installed CJK face for GB1, CNS1, Korea1 and Japan1, including GBK-EUC-H TrueType CID fonts without ToUnicode. Windows preserves installed SimSun, SimHei, KaiTi and FangSong families, including subset-prefixed Founder aliases. Combine thumbnails and accessibility figure previews use the same on-screen fallback. System faces are display-only, disabled by PDFCRAFT_SYSTEM_FONTS=0, and not used by export, printing, OCR or automation; machines without a candidate and web builds remain unsupported. A `/ToUnicode` given as the name `/Identity-H` or `/Identity-V` (WSCAD) reads codes as Unicode for selection, search and editing, without control characters. A non-embedded Identity font whose `/ToUnicode` gives a soft hyphen (U+00AD, as Windows exporters write Arial's hyphen) draws the standard substitute's hyphen (#5888). Any other character the substitute lacks still falls back to the CID taken as one of the substitute's glyph indexes, an unrelated glyph (#3529, #5726)" [[feature]] id = "view.render-image-codecs" @@ -818,7 +818,7 @@ 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", "crates/engine/src/tests.rs::cli_display_adds_missing_appearances_without_changing_the_source", "crates/engine/src/tests.rs::fields_left_to_the_viewer_are_displayed_from_their_values", "crates/forms/src/tests.rs::need_appearances_draws_every_text_and_choice_field_in_the_display_copy", "crates/forms/src/tests.rs::the_field_appearance_cache_sees_every_input", "crates/engine/src/tests.rs::buttons_and_combs_left_to_the_viewer_are_displayed", "crates/forms/src/tests.rs::check_boxes_without_an_appearance_show_their_caption_symbol", "crates/forms/src/tests.rs::push_buttons_without_an_appearance_show_their_caption_and_frame", "crates/forms/src/tests.rs::need_appearances_draws_check_boxes_and_radio_buttons_from_their_captions", "crates/engine/src/tests.rs::need_appearances_buttons_are_displayed_from_their_captions", "crates/render/src/raster.rs::invisible_unknown_annotations_and_popups_paint_nothing", "crates/print/src/tests.rs::invisible_annotations_of_unknown_types_do_not_print", "crates/engine/src/tests.rs::stamps_without_appearances_draw_the_stamp_they_name", "crates/automation/tests/it/automation.rs::page_render_draws_stamps_without_an_appearance"] +evidence = ["crates/render/src/raster/tests.rs::widget_appearance_states", "crates/render/src/raster/tests.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", "crates/engine/src/tests.rs::fields_left_to_the_viewer_are_displayed_from_their_values", "crates/forms/src/tests.rs::need_appearances_draws_every_text_and_choice_field_in_the_display_copy", "crates/forms/src/tests.rs::the_field_appearance_cache_sees_every_input", "crates/engine/src/tests.rs::buttons_and_combs_left_to_the_viewer_are_displayed", "crates/forms/src/tests.rs::check_boxes_without_an_appearance_show_their_caption_symbol", "crates/forms/src/tests.rs::push_buttons_without_an_appearance_show_their_caption_and_frame", "crates/forms/src/tests.rs::need_appearances_draws_check_boxes_and_radio_buttons_from_their_captions", "crates/engine/src/tests.rs::need_appearances_buttons_are_displayed_from_their_captions", "crates/render/src/raster/tests.rs::invisible_unknown_annotations_and_popups_paint_nothing", "crates/print/src/tests.rs::invisible_annotations_of_unknown_types_do_not_print", "crates/engine/src/tests.rs::stamps_without_appearances_draw_the_stamp_they_name", "crates/automation/tests/it/automation.rs::page_render_draws_stamps_without_an_appearance"] 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 stamp from another program draws the stamp its /Name names (the fourteen standard names of ISO 32000-2 Table 184, Draft when there is none) or, for any other name, a grey frame with both diagonals; text and choice fields are drawn from their values when the form sets /NeedAppearances (every one) or a widget has no /N (#3470), also display-only and cached per widget (a field edit on a 1000-field /NeedAppearances form takes ~13 ms against ~7 ms on a form that needs none) (a read-only document still gets the renderer's Highlight fallback only); check boxes and radio buttons with no appearance for their /AS are drawn display-only from /MK (background, border, and when on the /MK /CA ZapfDingbats symbol in the /DA colour, a check or a dot by default), and when the form sets /NeedAppearances so is every one that has an /MK, over the appearance the file carries (ISO 32000-2 Table 224; one without /MK keeps its own), and push buttons with none from /MK and /BS with their caption; Link borders without /AP and printing are not covered yet; an annotation of a non-standard type with the Invisible flag (ISO 32000-2 Table 167) is neither drawn nor printed, and a popup's own /AP is never painted on the page (its parent's text shows in the popup window, §12.5.6.14)" [[feature]] @@ -830,8 +830,8 @@ milestone = "M3" status = "shipped" commands = [] tools = [] -evidence = ["crates/render/src/raster.rs::missing_page_and_garbage_input_fail_gracefully", "crates/render/src/raster.rs::scale_is_clamped_for_huge_pages", "crates/render/src/raster.rs::watchdog_skips_a_stuck_page_and_keeps_rendering"] -notes = "" +evidence = ["crates/render/src/raster/tests.rs::missing_page_and_garbage_input_fail_gracefully", "crates/render/src/raster/tests.rs::scale_is_clamped_for_huge_pages", "crates/render/src/raster/tests.rs::watchdog_skips_a_stuck_page_and_keeps_rendering"] +notes = "Each page render runs under catch_unwind and a 20 second watchdog (STUCK_AFTER) that answers with an error and keeps a replacement worker rendering, and failed requests are remembered so they are not retried each frame. The cited tests cover garbage input, scale clamping and the watchdog; a page that overruns shows an error, but no screenshot of what the user sees was checked." [[feature]] id = "view.render-priority" @@ -842,8 +842,8 @@ milestone = "M3" status = "shipped" commands = [] tools = [] -evidence = ["crates/render/src/raster.rs::inline_pool_renders_in_priority_order"] -notes = "" +evidence = ["crates/render/src/raster/tests.rs::inline_pool_renders_in_priority_order"] +notes = "The canvas builds the request queue with visible pages and thumbnails first and text/search work last, and the pool answers in queue order. The cited test checks only the pool's order for two queued requests; the canvas ordering itself (prepare_render_queue) is not covered by a cited test, and no timing was measured." [[feature]] id = "view.deep-zoom-tiles" @@ -854,7 +854,7 @@ 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", "crates/ui-egui/tests/it/view.rs::returning_to_a_visited_zoom_reuses_its_cached_tiles", "crates/ui-egui/tests/it/view.rs::zooming_a_tiled_page_shows_its_earlier_tiles_until_new_ones_arrive"] +evidence = ["crates/render/src/raster/tests.rs::tiles_match_full_render", "crates/render/src/raster/tests.rs::effective_scale_never_exceeds_the_caps", "crates/ui-egui/tests/it/view.rs::returning_to_a_visited_zoom_reuses_its_cached_tiles", "crates/ui-egui/tests/it/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]] @@ -866,7 +866,7 @@ milestone = "M3" status = "shipped" commands = ["view.continuous", "view.page_gaps"] tools = [] -evidence = ["crates/ui-egui/tests/it/view.rs::page_gaps_in_organizer_do_not_move_the_document_scroll_position", "crates/ui-egui/tests/it/view.rs::page_gaps_comparison_menu_preserves_the_active_panes_choice", "crates/ui-egui/tests/it/view.rs::page_gaps_comparison_control_preserves_both_states", "crates/ui-egui/tests/it/view.rs::page_gaps_stay_closed_at_fractional_device_scales", "crates/ui-egui/tests/it/view.rs::page_gaps_toggle_joins_edges_without_changing_the_document", "crates/ui-egui/tests/it/view.rs::page_gaps_default_survives_restart_and_applies_to_new_documents", "crates/ui-egui/tests/it/view.rs::page_gaps_menu_joins_pages_without_a_shadow_at_the_seam", "crates/ui-egui/tests/it/view.rs::page_gaps_work_for_mixed_sizes_and_two_page_rows", "crates/ui-egui/tests/it/view.rs::continuous_layout_stacks_pages_vertically", "crates/ui-egui/tests/it/view.rs::page_display_commands_switch_layouts", "crates/ui-egui/tests/it/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/it/view.rs::touchpad_swipe_glides_on_after_the_fingers_lift_and_stops", "crates/ui-egui/tests/it/view.rs::touching_the_touchpad_again_or_turning_animations_off_stops_the_glide", "crates/ui-egui/tests/it/view.rs::rapid_wheel_notches_accelerate_and_follow_touchpad_input", "crates/ui-egui/tests/it/view.rs::changing_continuous_layout_resets_wheel_acceleration"] +evidence = ["crates/ui-egui/tests/it/view.rs::page_gaps_in_organizer_do_not_move_the_document_scroll_position", "crates/ui-egui/tests/it/view.rs::page_gaps_comparison_menu_preserves_the_active_panes_choice", "crates/ui-egui/tests/it/view.rs::page_gaps_comparison_control_preserves_both_states", "crates/ui-egui/tests/it/view.rs::page_gaps_stay_closed_at_fractional_device_scales", "crates/ui-egui/tests/it/view.rs::page_gaps_toggle_joins_edges_without_changing_the_document", "crates/ui-egui/tests/it/view.rs::page_gaps_default_survives_restart_and_applies_to_new_documents", "crates/ui-egui/tests/it/view.rs::page_gaps_menu_joins_pages_without_a_shadow_at_the_seam", "crates/ui-egui/tests/it/view.rs::page_gaps_work_for_mixed_sizes_and_two_page_rows", "crates/ui-egui/tests/it/view.rs::continuous_layout_stacks_pages_vertically", "crates/ui-egui/tests/it/view.rs::page_display_commands_switch_layouts", "crates/ui-egui/tests/it/view.rs::default_page_display_is_used_for_new_documents", "crates/ui-egui/src/canvas/tests.rs::fit_page_and_height_hold_still_while_mixed_page_sizes_scroll_past", "crates/ui-egui/tests/it/view.rs::touchpad_swipe_glides_on_after_the_fingers_lift_and_stops", "crates/ui-egui/tests/it/view.rs::touching_the_touchpad_again_or_turning_animations_off_stops_the_glide", "crates/ui-egui/tests/it/view.rs::rapid_wheel_notches_accelerate_and_follow_touchpad_input", "crates/ui-egui/tests/it/view.rs::changing_continuous_layout_resets_wheel_acceleration"] notes = "Page gaps, shadows and outlines can be hidden through the page-display menu, with a persisted default in Preferences and matching UI control options; page margins in the PDF are unchanged. Continuous scrolling remains 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. Rapid line-wheel notches gain a bounded, constant-space arrival-rate boost (#1072); isolated notches still use the chosen Scroll speed, and page units stay unboosted. 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]] @@ -890,8 +890,8 @@ milestone = "M3" status = "shipped" commands = ["view.layout.two_up"] tools = [] -evidence = ["crates/ui-egui/tests/it/view.rs::two_up_layout_places_pages_side_by_side", "crates/ui-egui/tests/it/viewer.rs::two_page_view_steps_a_spread_at_a_time"] -notes = "next/previous page step a spread at a time" +evidence = ["crates/ui-egui/tests/it/view.rs::two_up_layout_places_pages_side_by_side", "crates/ui-egui/tests/it/viewer.rs::two_page_view_steps_a_spread_at_a_time", "apps/pdfcraft/src/startup.rs::startup_override_is_per_file_and_does_not_persist", "crates/ui-egui/src/platform/open_worker/tests.rs::flagged_startup_opens_wait_for_password_ownership_before_the_first_frame"] +notes = "Next/previous page step a spread at a time. Optional desktop --two-paged launches named PDFs in noncontinuous two-page view without changing saved defaults or unrelated restored tabs. Explicit --layout wins after flagged opens settle, including password retries; repeated files reuse their tab. This is a launch override, not a declarative configuration module." [[feature]] id = "view.layout-two-up-scrolling" @@ -938,8 +938,8 @@ milestone = "M3" status = "partial" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/it/control.rs::middle_click_autoscroll_latches_has_a_dead_zone_and_scrolls_both_directions", "crates/ui-egui/tests/it/control.rs::organize_pages_supports_autoscroll_without_selecting_or_reordering_pages", "crates/ui-egui/tests/it/control.rs::moving_before_middle_button_release_keeps_scrolling_without_starting_page_tools", "crates/ui-egui/tests/it/control.rs::farther_from_the_click_scrolls_faster_in_the_viewer_and_page_grid", "crates/ui-egui/tests/it/control.rs::autoscroll_uses_the_initial_click_position_when_input_arrives_in_one_frame", "crates/ui-egui/tests/it/control.rs::autoscroll_uses_elapsed_frame_time_and_preserves_fractional_motion_in_both_views", "crates/ui-egui/tests/it/control.rs::middle_button_pans_while_held_outside_linux", "crates/ui-egui/tests/it/comments.rs::a_middle_drag_with_a_drawing_tool_scrolls_instead_of_drawing"] -notes = "Linux-only middle-button vertical autoscroll in the document and Organize Pages; Windows and macOS pan 1:1 while the middle button is held (Grabbing cursor, no latch). On every target the middle button never reaches the page tools, so it scrolls instead of drawing, selecting, cropping or moving. Click to toggle, release keeps scrolling on, and a Chromium-style nonlinear curve increases speed with vertical screen distance from the initial click. A 15-point dead zone pauses immediately. Continuous redraws use elapsed frame time and preserve fractional movement, tested at 30/60/120/144 fps. Cancels on another middle click, Escape, other pointer/wheel input, focus loss, tab changes and dialogs. Driven headlessly through the opt-in UI control channel (ui.click/ui.move/ui.drag). Keyboard-started timed scrolling is not implemented." +evidence = ["crates/ui-egui/tests/it/control.rs::middle_click_autoscroll_latches_has_a_dead_zone_and_scrolls_both_directions", "crates/ui-egui/tests/it/control.rs::organize_pages_supports_autoscroll_without_selecting_or_reordering_pages", "crates/ui-egui/tests/it/control.rs::moving_before_middle_button_release_keeps_scrolling_without_starting_page_tools", "crates/ui-egui/tests/it/control.rs::farther_from_the_click_scrolls_faster_in_the_viewer_and_page_grid", "crates/ui-egui/tests/it/control.rs::autoscroll_uses_the_initial_click_position_when_input_arrives_in_one_frame", "crates/ui-egui/tests/it/control.rs::autoscroll_uses_elapsed_frame_time_and_preserves_fractional_motion_in_both_views", "crates/ui-egui/tests/it/control.rs::middle_button_pans_while_held_outside_linux", "crates/ui-egui/tests/it/comments.rs::a_middle_drag_with_a_drawing_tool_scrolls_instead_of_drawing", "crates/ui-egui/tests/it/control.rs::inverted_middle_drag_moves_the_view_the_way_the_pointer_goes", "crates/ui-egui/tests/it/viewer.rs::the_invert_middle_drag_preference_is_off_by_default_and_kept_in_the_settings"] +notes = "Linux-only middle-button vertical autoscroll in the document and Organize Pages; Windows and macOS pan 1:1 while the middle button is held (Grabbing cursor, no latch). On every target the middle button never reaches the page tools, so it scrolls instead of drawing, selecting, cropping or moving. Click to toggle, release keeps scrolling on, and a Chromium-style nonlinear curve increases speed with vertical screen distance from the initial click. A 15-point dead zone pauses immediately. Continuous redraws use elapsed frame time and preserve fractional movement, tested at 30/60/120/144 fps. Cancels on another middle click, Escape, other pointer/wheel input, focus loss, tab changes and dialogs. Preferences ▸ Invert middle-button drag (off by default, saved; ui.set invert_middle_drag) reverses the direction of both the pan and the auto-scroll (#5790); it changes direction only, not which of the two a platform uses. Driven headlessly through the opt-in UI control channel (ui.click/ui.move/ui.drag). Keyboard-started timed scrolling is not implemented." [[feature]] id = "view.zoom-in-out" @@ -1022,7 +1022,7 @@ milestone = "M3" status = "shipped" commands = ["view.fit_visible"] tools = [] -evidence = ["crates/ui-egui/tests/it/viewer.rs::fit_visible_zooms_to_the_content_width", "crates/ui-egui/src/zoom_snap.rs::ink_bounds_finds_the_marked_area"] +evidence = ["crates/ui-egui/tests/it/viewer.rs::fit_visible_zooms_to_the_content_width", "crates/ui-egui/src/canvas/zoom_snap.rs::ink_bounds_finds_the_marked_area"] notes = "View ▸ Zoom ▸ Fit visible (⌘3): the current page's inked area (found by a quick low-resolution render) spans the window width; respects view rotation; drivable via the control channel's command" [[feature]] @@ -1095,7 +1095,7 @@ status = "shipped" commands = [] tools = [] evidence = ["crates/ui-egui/tests/it/ui.rs::selection_works_in_every_view_rotation"] -notes = "" +notes = "View menu rotate clockwise and counter-clockwise turn the view in 90 degree steps per open tab, and the page is not changed (the /Rotate entries are untouched). The cited test checks text selection under rotation; the view rotation is not remembered after closing and has no headless tool." [[feature]] id = "view.read-mode" @@ -1178,8 +1178,8 @@ milestone = "M3" status = "shipped" commands = ["view.previous_view", "view.next_view"] tools = [] -evidence = ["crates/ui-egui/tests/it/viewer.rs::view_history_select_all_and_find_options", "crates/ui-egui/tests/it/control.rs::navigation_history_commands_and_rail_are_per_tab_and_non_editing", "crates/ui-egui/tests/it/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." +evidence = ["crates/ui-egui/tests/it/viewer.rs::view_history_select_all_and_find_options", "crates/ui-egui/tests/it/control.rs::navigation_history_commands_and_rail_are_per_tab_and_non_editing", "crates/ui-egui/tests/it/control.rs::routine_continuous_scrolling_does_not_add_navigation_history", "crates/ui-egui/tests/it/viewer.rs::alt_arrows_go_back_and_forward_through_the_view_history", "crates/ui-egui/tests/it/editing.rs::alt_arrows_navigate_instead_of_nudging_a_selected_image", "crates/ui-egui/tests/it/comments.rs::alt_arrows_navigate_instead_of_nudging_the_selected_comment"] +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, Ctrl/⌘[ / Ctrl/⌘] and Alt/⌥+← / →, also with a picture, object or comment selected (the View menu shows only the bracket shortcuts). 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" @@ -1190,7 +1190,7 @@ milestone = "M3" status = "shipped" commands = [] tools = ["link_open"] -evidence = ["crates/ui-egui/tests/it/view.rs::clicking_a_link_goes_to_its_destination", "crates/ui-egui/tests/it/view.rs::link_destinations_scroll_to_their_position", "crates/ui-egui/tests/it/view.rs::a_link_without_a_position_keeps_the_place_on_the_next_page", "crates/ui-egui/src/canvas.rs::a_null_xyz_coordinate_keeps_the_readers_place_on_the_new_page", "crates/render/src/inspect.rs::bookmarks_and_links_keep_their_destination_view", "crates/render/src/inspect.rs::links_read_bounded_local_remote_go_to_actions", "crates/automation/tests/it/automation.rs::local_gotor_link_open_applies_destinations_and_honors_root", "crates/ui-egui/tests/it/document_links.rs::a_local_gotor_link_opens_in_a_tab_and_applies_its_destination", "crates/ui-egui/tests/it/document_links.rs::an_absolute_gotor_waits_for_consent_and_keeps_its_destination_across_tabs", "crates/ui-egui/tests/it/document_links.rs::cancelling_an_absolute_gotor_by_button_escape_or_dismissal_changes_nothing", "crates/automation/tests/it/automation.rs::absolute_gotor_consent_does_not_bypass_validation_or_passwords", "crates/render/src/inspect.rs::launch_actions_that_name_a_pdf_open_it_like_a_remote_link", "crates/automation/tests/it/automation.rs::a_launch_link_to_a_pdf_opens_it_like_a_remote_link"] +evidence = ["crates/ui-egui/tests/it/view.rs::clicking_a_link_goes_to_its_destination", "crates/ui-egui/tests/it/view.rs::link_destinations_scroll_to_their_position", "crates/ui-egui/tests/it/view.rs::a_link_without_a_position_keeps_the_place_on_the_next_page", "crates/ui-egui/src/canvas/tests.rs::a_null_xyz_coordinate_keeps_the_readers_place_on_the_new_page", "crates/render/src/inspect/tests.rs::bookmarks_and_links_keep_their_destination_view", "crates/render/src/inspect/tests.rs::links_read_bounded_local_remote_go_to_actions", "crates/automation/tests/it/automation.rs::local_gotor_link_open_applies_destinations_and_honors_root", "crates/ui-egui/tests/it/document_links.rs::a_local_gotor_link_opens_in_a_tab_and_applies_its_destination", "crates/ui-egui/tests/it/document_links.rs::an_absolute_gotor_waits_for_consent_and_keeps_its_destination_across_tabs", "crates/ui-egui/tests/it/document_links.rs::cancelling_an_absolute_gotor_by_button_escape_or_dismissal_changes_nothing", "crates/automation/tests/it/automation.rs::absolute_gotor_consent_does_not_bypass_validation_or_passwords", "crates/render/src/inspect/tests.rs::launch_actions_that_name_a_pdf_open_it_like_a_remote_link", "crates/automation/tests/it/automation.rs::a_launch_link_to_a_pdf_opens_it_like_a_remote_link"] notes = "GoTo and URI links plus local-PDF GoToR links: source-relative or local absolute /F paths, /UF preferred in file-spec dictionaries, explicit numeric page/view destinations, and internal tabs. Absolute local GoToR targets require desktop Allow/Cancel confirmation or an explicit allow_absolute opt-in in link_open; relative links open normally. link_open honors --root. Named remote destinations open at default view with a warning; /NewWindow is a tab hint. A Launch action whose /F names a .pdf file (and has no /Win launch parameters) opens that PDF the same way, backslash separators read as / (#4168); URL/network file systems, GoToE, other Launch actions and other actions are not executed. Same-document destinations retain their supported /XYZ, /Fit, /FitH, /FitV and /FitR views; /FitB* is read as whole-page fit. A null /XYZ left or top keeps the reader's place, as ISO 32000-2 §12.3.2.2 says and Acrobat Reader was observed to do (synthetic fixture): the user-space point at the window's top-left stays there on the target page at the same zoom, along each axis the page overflows; CAD exports (WSCAD) write every cross-reference this way. Null /FitH top and /FitV left still go to the page edge." [[feature]] @@ -1202,8 +1202,8 @@ milestone = "M3" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/it/comments.rs::hovering_a_comment_shows_its_author_and_text", "crates/ui-egui/tests/it/comments.rs::multiline_markup_hover_stays_on_marked_lines", "crates/ui-egui/tests/it/comments.rs::neighboring_markup_hover_changes_to_its_own_comment", "crates/ui-egui/tests/it/comments.rs::overlapping_markup_hover_shows_the_topmost_comment", "crates/ui-egui/tests/it/comments.rs::overlapping_markup_hover_uses_annotation_index_with_different_top_edges", "crates/ui-egui/tests/it/comments.rs::markup_hover_without_quads_keeps_the_rectangle_fallback", "crates/ui-egui/tests/it/comments.rs::markup_hover_skips_popup_containers", "crates/ui-egui/tests/it/comments.rs::preference_hides_author_only_highlight_hover_previews"] -notes = "A popup with the author and comment text. Text markup uses selection's per-quad screen bounds, including page and view rotation, so unmarked parts of a multiline Rect do not claim the hover (#3541). The last-painted eligible comment wins where annotations overlap (#3620), using the original /Annots index independently of spatial inspection order. Missing complete quads retain the Rect fallback; author-only highlight hiding and dialog suppression remain. Bounds are rectangular, not exact hit testing of skewed quadrilaterals. Pointer feedback is UI-only, reachable through the opt-in control channel, with no document automation tool." +evidence = ["crates/ui-egui/tests/it/comments.rs::hovering_a_comment_shows_its_author_and_text", "crates/ui-egui/tests/it/pointer.rs::a_comments_tip_shows_with_every_tool_beside_its_cursor", "crates/ui-egui/tests/it/comments.rs::multiline_markup_hover_stays_on_marked_lines", "crates/ui-egui/tests/it/comments.rs::neighboring_markup_hover_changes_to_its_own_comment", "crates/ui-egui/tests/it/comments.rs::overlapping_markup_hover_shows_the_topmost_comment", "crates/ui-egui/tests/it/comments.rs::overlapping_markup_hover_uses_annotation_index_with_different_top_edges", "crates/ui-egui/tests/it/comments.rs::markup_hover_without_quads_keeps_the_rectangle_fallback", "crates/ui-egui/tests/it/comments.rs::markup_hover_skips_popup_containers", "crates/ui-egui/tests/it/comments.rs::preference_hides_author_only_highlight_hover_previews"] +notes = "A popup with the author and comment text, as Acrobat shows it: with every tool, beside that tool's own cursor (Acrobat does the same with Select, Hand, Add comment, Highlight, Rectangle and Stamp); not with Fill & Sign, while drawing freehand (#429) or while a comment is moved, resized or drawn. Text markup uses selection's per-quad screen bounds, including page and view rotation, so unmarked parts of a multiline Rect do not claim the hover (#3541). The last-painted eligible comment wins where annotations overlap (#3620), using the original /Annots index independently of spatial inspection order. Missing complete quads retain the Rect fallback; author-only highlight hiding and dialog suppression remain. Bounds are rectangular, not exact hit testing of skewed quadrilaterals. Still missing: Acrobat's hover outline on a highlight with every tool, and its hit on an unfilled shape's border only. Pointer feedback is UI-only, reachable through the opt-in control channel, with no document automation tool." [[feature]] id = "view.page-size-hover" @@ -1214,7 +1214,7 @@ milestone = "M3" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/it/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"] +evidence = ["crates/ui-egui/tests/it/control.rs::page_size_shows_while_the_pointer_is_in_the_bottom_left_quadrant", "crates/ui-egui/src/canvas/tests.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]] @@ -1286,8 +1286,8 @@ milestone = "M4" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/it/editing.rs::dragging_pages_panel_thumbnails_reorders_pages", "crates/ui-egui/tests/it/viewer.rs::pages_panel_thumbnails_have_a_context_menu", "crates/ui-egui/tests/it/editing.rs::split_from_pages_panel_context_menu", "crates/ui-egui/tests/it/editing.rs::deleting_pages_from_the_pages_panel_menu", "crates/ui-egui/tests/it/editing.rs::the_delete_key_deletes_pages_picked_in_the_pages_panel_but_never_every_page"] -notes = "Drag the selection (or the page grabbed) to reorder, with a drop bar and edge scrolling; right-click offers Split document…, Extract pages, Cut, Copy, Paste after and Delete pages (also the Delete key over the panel). Not yet: Rotate, Insert and Page labels in the menu, dragging pages between documents, or dropping files onto the panel." +evidence = ["crates/ui-egui/tests/it/view.rs::pages_sidebar_select_all_keeps_current_page_and_keyboard_scope", "crates/ui-egui/tests/it/editing.rs::dragging_pages_panel_thumbnails_reorders_pages", "crates/ui-egui/tests/it/viewer.rs::pages_panel_thumbnails_have_a_context_menu", "crates/ui-egui/tests/it/editing.rs::split_from_pages_panel_context_menu", "crates/ui-egui/tests/it/editing.rs::deleting_pages_from_the_pages_panel_menu", "crates/ui-egui/tests/it/editing.rs::the_delete_key_deletes_pages_picked_in_the_pages_panel_but_never_every_page"] +notes = "Ctrl/Cmd+A selects every page while the Pages sidebar owns the keyboard, without navigation; Escape clears that selection. Viewer and text-field shortcuts keep their own scope. Drag the selection (or the page grabbed) to reorder, with a drop bar and edge scrolling; right-click offers Split document…, Extract pages, Cut, Copy, Paste after and Delete pages (also the Delete key over the panel). Deleting pages makes the next Save a full rewrite (unsigned files), so the deleted pages leave the file; signed files stay incremental. Not yet: Rotate, Insert and Page labels in the menu, dragging pages between documents, or dropping files onto the panel." [[feature]] id = "view.bookmarks-panel" @@ -1322,7 +1322,7 @@ milestone = "M3" status = "shipped" commands = [] tools = ["doc_info"] -evidence = ["crates/render/src/inspect.rs::attachments_of_both_kinds_are_listed_and_extracted", "crates/ui-egui/tests/it/attachment_row.rs::the_right_click_menu_opens_saves_and_deletes", "crates/ui-egui/tests/it/attachment_row.rs::double_clicking_a_pdf_attachment_opens_it_in_a_new_tab"] +evidence = ["crates/render/src/inspect/tests.rs::attachments_of_both_kinds_are_listed_and_extracted", "crates/ui-egui/tests/it/attachment_row.rs::the_right_click_menu_opens_saves_and_deletes", "crates/ui-egui/tests/it/attachment_row.rs::double_clicking_a_pdf_attachment_opens_it_in_a_new_tab"] notes = "Each row has Save, Open in a new tab (PDFs) and Delete (the document's own attachments) buttons, the same items on a right-click, and a double-click that opens a PDF in a new tab or saves any other file (#3466). Not yet: Acrobat's Search attachments, and opening a non-PDF attachment in another program (it is saved instead)." [[feature]] @@ -1334,7 +1334,7 @@ milestone = "M3" status = "shipped" commands = [] tools = ["doc_info", "layer_set"] -evidence = ["crates/render/src/raster.rs::layer_override_hides_content", "crates/engine/src/tests.rs::layer_choices_survive_edits", "crates/automation/tests/it/automation.rs::layers_and_hidden_comments_change_what_page_render_draws"] +evidence = ["crates/render/src/raster/tests.rs::layer_override_hides_content", "crates/engine/src/tests.rs::layer_choices_survive_edits", "crates/automation/tests/it/automation.rs::layers_and_hidden_comments_change_what_page_render_draws"] notes = "layer_set shows or hides a layer by name (every group with that name, as CAD exporters repeat names) or by position, and page_render draws with the document's layer visibility. Still missing: Reset to initial visibility (view.layers-reset), layer properties, and /OC on annotations (#2176)." [[feature]] @@ -1346,7 +1346,7 @@ milestone = "M12" status = "partial" commands = [] tools = ["doc_info"] -evidence = ["crates/engine/src/tests.rs::reset_layers_returns_to_the_files_visibility", "crates/render/src/inspect.rs::initial_layer_visibility_is_the_default_configuration"] +evidence = ["crates/engine/src/tests.rs::reset_layers_returns_to_the_files_visibility", "crates/render/src/inspect/tests.rs::initial_layer_visibility_is_the_default_configuration"] notes = "Layers panel ▸ Layer options ▸ Reset to Initial Visibility shows each layer as the default configuration (/OCProperties /D) has it; doc_info reports it as `initial`. Not yet: listing the layers of all pages or the current page only." [[feature]] @@ -1394,8 +1394,8 @@ milestone = "M3" status = "shipped" commands = [] tools = ["doc_info"] -evidence = ["crates/render/src/inspect.rs::roman_and_alpha_labels", "crates/automation/tests/it/automation.rs::open_inspect_render_and_find"] -notes = "" +evidence = ["crates/render/src/inspect/tests.rs::roman_and_alpha_labels", "crates/automation/tests/it/automation.rs::open_inspect_render_and_find"] +notes = "Page labels from /PageLabels (decimal, roman, letters, prefixes) show in the page box and thumbnails (long labels are cut to the column), and doc_info lists them. The cited tests cover roman and alpha numbering and the tool listing; labels in a nested number tree and in the page-input box are not covered by them." [[feature]] id = "view.find" @@ -1406,7 +1406,7 @@ milestone = "M3" status = "shipped" commands = ["edit.find"] tools = ["text_find"] -evidence = ["crates/ui-egui/tests/it/ui.rs::find_counts_matches_across_pages", "crates/ui-egui/tests/it/ui.rs::find_reports_no_matches", "crates/automation/tests/it/automation.rs::open_inspect_render_and_find", "crates/render/src/text.rs::identity_v_columns_are_searchable_in_reading_order", "crates/render/src/text.rs::toc_leaders_do_not_hide_word_gaps", "crates/render/src/text.rs::short_tracked_ellipses_remain_searchable"] +evidence = ["crates/ui-egui/tests/it/ui.rs::find_counts_matches_across_pages", "crates/ui-egui/tests/it/ui.rs::find_reports_no_matches", "crates/automation/tests/it/automation.rs::open_inspect_render_and_find", "crates/render/src/text/tests.rs::identity_v_columns_are_searchable_in_reading_order", "crates/render/src/text/tests.rs::toc_leaders_do_not_hide_word_gaps", "crates/render/src/text/tests.rs::short_tracked_ellipses_remain_searchable"] notes = "Horizontal and WMode 1 vertical text are grouped independently, including a horizontal heading above right-to-left vertical columns. Dotted TOC leaders preserve word spaces for phrase search (#3086), while short tracked ellipses remain intact and searchable; sparse lines and condensed OCR gaps below the existing height-based threshold remain limitations. Complex interleaved flows still use a geometric reading-order heuristic." [[feature]] @@ -1538,7 +1538,7 @@ milestone = "M3" status = "shipped" commands = ["edit.column_select"] tools = ["text_extract"] -evidence = ["crates/render/src/text.rs::a_column_selection_takes_one_table_column", "crates/render/src/text.rs::a_column_selection_across_columns_reads_row_by_row", "crates/render/src/text.rs::right_to_left_rows_read_right_to_left", "crates/render/src/text.rs::vertical_lines_keep_reading_order", "crates/ui-egui/tests/it/column_select.rs::alt_drag_selects_only_the_text_inside_the_rectangle", "crates/ui-egui/tests/it/column_select.rs::the_column_select_tool_needs_no_modifier", "crates/ui-egui/tests/it/column_select.rs::alt_drag_with_the_highlighter_marks_only_the_column", "crates/automation/tests/it/automation.rs::text_extract_with_a_rect_takes_one_column"] +evidence = ["crates/render/src/text/tests.rs::a_column_selection_takes_one_table_column", "crates/render/src/text/tests.rs::a_column_selection_across_columns_reads_row_by_row", "crates/render/src/text/tests.rs::right_to_left_rows_read_right_to_left", "crates/render/src/text/tests.rs::vertical_lines_keep_reading_order", "crates/ui-egui/tests/it/column_select.rs::alt_drag_selects_only_the_text_inside_the_rectangle", "crates/ui-egui/tests/it/column_select.rs::the_column_select_tool_needs_no_modifier", "crates/ui-egui/tests/it/column_select.rs::alt_drag_with_the_highlighter_marks_only_the_column", "crates/automation/tests/it/automation.rs::text_extract_with_a_rect_takes_one_column"] notes = "Issue #740. Holding Alt (Option on macOS) when a drag starts selects only the text inside the rectangle drawn, with the Select tool, the text markup tools and Redact (with Highlight and Redact the drag must start on text, since off text they draw an area box). Edit ▸ Column select does the same with a plain drag. A glyph counts when its centre is inside. Copy gives one row per visual line, top to bottom; pieces side by side (table cells) are separated by a tab, right to left in right-to-left rows; vertical text keeps reading order. text_extract takes the same rectangle as rect. Platforms: on Linux desktops whose window manager moves windows with Alt-drag (Xfce, Cinnamon and MATE by default; KDE Plasma 5 and other setups with Alt as the window modifier; GNOME and Plasma 6 use Super) the drag never reaches PdfCraft, so use Edit ▸ Column select or change that setting. On Windows, releasing Alt does not open a system menu (the window has no native menu). The web build has the same Linux caveat in the browser. Checked by hand on Linux (Fedora) with the Column select tool; Alt/Option-drag, and macOS and Windows generally, rest on the kittest tests. Missing: selection across pages, ⇧-click extending a column, a default shortcut, and a check of Acrobat's own copied-text format (not observed)." [[feature]] @@ -1562,7 +1562,7 @@ milestone = "M3" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/it/viewer.rs::view_history_select_all_and_find_options", "crates/ui-egui/src/canvas.rs::select_all_copies_the_whole_document_while_scrolling_continuously", "crates/ui-egui/src/canvas.rs::select_all_of_the_document_only_while_scrolling_through_several_pages"] +evidence = ["crates/ui-egui/tests/it/viewer.rs::view_history_select_all_and_find_options", "crates/ui-egui/src/canvas/tests.rs::select_all_copies_the_whole_document_while_scrolling_continuously", "crates/ui-egui/src/canvas/tests.rs::select_all_of_the_document_only_while_scrolling_through_several_pages"] notes = "⌘A: the current page one page at a time; the whole document scrolling continuously (#2639), copied whole" [[feature]] @@ -1598,8 +1598,8 @@ milestone = "M3" status = "partial" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/it/viewer.rs::a_clicked_picture_is_selected_and_copied_in_its_original_format", "crates/ui-egui/tests/it/viewer.rs::right_click_on_a_picture_copies_it_at_full_size", "crates/ui-egui/tests/it/viewer.rs::a_picture_inside_form_artwork_under_text_is_selected_and_copied", "crates/engine/src/tests.rs::copy_image_finds_a_picture_inside_form_artwork_under_text"] -notes = "Select tool: a click selects a raster image (also inside Form artwork or under text) and ⌘C copies it as a file in its original format (JPEG as stored, else PNG); right-click ▸ Copy Image copies its pixels at full resolution. Missing: inline images (BI/ID/EI), JPEG 2000/JBIG2/CCITT images, multi-format clipboard (file and bitmap at once), the web build" +evidence = ["crates/ui-egui/tests/it/viewer.rs::a_clicked_picture_is_selected_and_copied_in_its_original_format", "crates/ui-egui/tests/it/viewer.rs::right_click_on_a_picture_copies_it_at_full_size", "crates/ui-egui/tests/it/viewer.rs::a_picture_inside_form_artwork_under_text_is_selected_and_copied", "crates/engine/src/tests.rs::copy_image_finds_a_picture_inside_form_artwork_under_text", "crates/engine/src/tests.rs::copy_image_copies_a_jbig2_picture_behind_another_filter", "crates/engine/src/tests.rs::copy_image_reads_the_globals_of_a_jbig2_picture"] +notes = "Select tool: a click selects a raster image (also inside Form artwork or under text) and ⌘C copies it as a file in its original format (JPEG as stored, else PNG); right-click ▸ Copy Image copies its pixels at full resolution. Missing: inline images (BI/ID/EI), JPEG 2000 images, multi-format clipboard (file and bitmap at once), the web build" [[feature]] id = "view.rulers" @@ -1670,7 +1670,7 @@ milestone = "M3" status = "shipped" commands = ["view.theme", "view.theme.light", "view.theme.dark"] tools = [] -evidence = ["crates/ui-egui/tests/it/theme.rs::toolbar_and_view_menu_share_three_state_preferences", "crates/ui-egui/src/theme.rs::text_is_readable_on_every_surface"] +evidence = ["crates/ui-egui/tests/it/theme.rs::toolbar_and_view_menu_share_three_state_preferences", "crates/ui-egui/src/theme/tests.rs::text_is_readable_on_every_surface"] notes = "Toolbar and Display theme share three choices and a preference setter; manual choices exit system mode. Every text colour meets WCAG AA (4.5:1) on every surface in both themes; glyphs are pixel-aligned for sharper text at 100–150 % scaling." [[feature]] @@ -1799,11 +1799,28 @@ title = "Customise the quick-action toolbar and pinned tools" area = "B" tier = "P2" milestone = "M14" -status = "planned" +status = "partial" commands = [] tools = [] -evidence = [] -notes = "" +evidence = [ + "crates/ui-egui/tests/it/pinned_tools.rs::state_operations_and_validation", + "crates/ui-egui/tests/it/pinned_tools.rs::persistence_round_trip_and_sanitization", + "crates/ui-egui/tests/it/pinned_tools.rs::home_shows_pinned_tools_section_and_placeholder", + "crates/ui-egui/tests/it/pinned_tools.rs::pin_tool_dialog_workflow", + "crates/ui-egui/tests/it/pinned_tools.rs::sidebar_pin_toggle_unpins_and_pins", + "crates/ui-egui/tests/it/comments.rs::hotbar_picker_adds_and_runs_freehand_highlight", + "crates/ui-egui/tests/it/comments.rs::hotbar_drag_moves_and_persists_position", + "crates/ui-egui/tests/it/comments.rs::hotbar_vertical_size_and_docking", + "crates/ui-egui/tests/it/comments.rs::hotbar_settings_reopen_even_with_remembered_collapsed_state", + "crates/ui-egui/tests/it/comments.rs::hotbar_full_side_strip_spans_each_edge_and_returns_to_float", + "crates/ui-egui/tests/it/comments.rs::hotbar_docked_left_does_not_override_quick_tools", + "crates/ui-egui/tests/it/comments.rs::hotbar_docked_in_compact_window_keeps_settings_button_visible", + "crates/ui-egui/tests/it/comments.rs::hotbar_settings_menu_closes_on_click_outside", + "crates/ui-egui/tests/it/comments.rs::hotbar_picker_in_narrow_viewport_fits_within_screen_width", + "crates/ui-egui/tests/it/comments.rs::hotbar_floating_picker_does_not_overlap_rendered_bar", + "crates/ui-egui/tests/it/comments.rs::hotbar_settings_layout_and_side_controls_are_reachable_by_label", +] +notes = "Pin and unpin tools on the Home page via tool card hover button, All Tools row toggle, or 'Pin a tool' search dialog. Pinned tool order persists across sessions. Customizable document hotbar searches the command registry, adds/removes tools, resizes buttons from 24 to 64 pixels, floats or docks on any side with an optional full-side chrome strip, and persists preferences (UI control: hotbar-tools, hotbar-size, hotbar-side, hotbar-full-side). Missing: hotbar shortcut reordering and full Acrobat customization-dialog parity." [[feature]] id = "view.restriction-notice" @@ -1878,8 +1895,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/it/editing.rs::editing_existing_text_in_place", "crates/automation/tests/it/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/it/automation.rs::a_line_split_across_content_streams_is_listed_and_edited", "crates/ui-egui/tests/it/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/it/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/it/document_fonts.rs::opentype_cff_document_font_preserves_program_codes_and_rendering_after_reopen", "crates/ui-egui/tests/it/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/it/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/it/editing.rs::one_editor_contains_mixed_styles_and_visual_rows", "crates/ui-egui/tests/it/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/it/automation.rs::mixed_visual_region_edit_preview_save_reopen_and_undo_through_tools", "crates/ui-egui/tests/it/ui.rs::the_edit_tab_turns_on_edit_text_and_images", "crates/ui-egui/tests/it/editing.rs::the_caret_on_a_small_row_is_as_tall_as_that_row", "crates/edit/src/tests.rs::uzbek_and_kazakh_cyrillic_use_the_bundled_latin_face", "crates/automation/tests/it/automation.rs::editing_uzbek_and_kazakh_cyrillic_through_tools", "crates/edit/src/tests.rs::styled_paragraph_batches_cyrillic_without_changing_source_fonts", "crates/edit/src/tests.rs::styled_paragraph_over_240_fallback_glyphs_splits_and_refuses_atomically", "crates/edit/src/tests.rs::styled_fallback_batches_keep_face_choices_and_range_styles", "crates/edit/src/tests.rs::styled_fallback_shards_keep_text_advances_and_alignment", "crates/edit/src/tests.rs::styled_fallback_budget_is_enforced_before_objects_are_written", "crates/edit/src/tests.rs::styled_fallback_same_base_font_and_character_keep_both_weights", "crates/automation/tests/it/automation.rs::styled_cyrillic_fallback_is_batched_through_tools_and_reopen"] -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. Replacement text the paragraph's font can't show takes the craft-fonts Japanese faces when they draw all of it (Uzbek қ, Kazakh ә are in none of them), else the bundled Inter face, so those letters write in every build (#2593). Legacy non-SFNT preview, mixed-font/style contextual runs, full structure-tree preservation and author intent in untagged PDFs remain limited; Styled-path outline fallback batches by actual face with 240 glyphs per font and preserves source styles; adjacent equal-font/style characters form runs. At most 15360 distinct fallback glyphs per styled paragraph; missing glyphs are refused before fallback objects are written. Glyph coverage, mixed contextual shaping, single-style fallback and stale-font cleanup are unchanged; 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/it/editing.rs::editing_existing_text_in_place", "crates/automation/tests/it/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/it/automation.rs::a_line_split_across_content_streams_is_listed_and_edited", "crates/ui-egui/tests/it/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/it/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/it/document_fonts.rs::opentype_cff_document_font_preserves_program_codes_and_rendering_after_reopen", "crates/ui-egui/tests/it/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/it/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/it/editing.rs::one_editor_contains_mixed_styles_and_visual_rows", "crates/ui-egui/tests/it/editing.rs::inline_editor_preserves_source_font_and_previews_live_bounds", "crates/fonts/src/pdf/tests.rs::mac_subset_cmap_uses_pdf_unicode_without_changing_source_program", "crates/automation/tests/it/automation.rs::mixed_visual_region_edit_preview_save_reopen_and_undo_through_tools", "crates/ui-egui/tests/it/ui.rs::the_edit_tab_turns_on_edit_text_and_images", "crates/ui-egui/tests/it/editing.rs::the_caret_on_a_small_row_is_as_tall_as_that_row", "crates/edit/src/tests.rs::uzbek_and_kazakh_cyrillic_use_the_bundled_latin_face", "crates/automation/tests/it/automation.rs::editing_uzbek_and_kazakh_cyrillic_through_tools", "crates/edit/src/tests.rs::styled_paragraph_batches_cyrillic_without_changing_source_fonts", "crates/edit/src/tests.rs::styled_paragraph_over_240_fallback_glyphs_splits_and_refuses_atomically", "crates/edit/src/tests.rs::styled_fallback_batches_keep_face_choices_and_range_styles", "crates/edit/src/tests.rs::styled_fallback_shards_keep_text_advances_and_alignment", "crates/edit/src/tests.rs::styled_fallback_budget_is_enforced_before_objects_are_written", "crates/edit/src/tests.rs::styled_fallback_same_base_font_and_character_keep_both_weights", "crates/automation/tests/it/automation.rs::styled_cyrillic_fallback_is_batched_through_tools_and_reopen", "crates/ui-egui/tests/it/editing.rs::escape_in_an_open_paragraph_selects_its_box_for_delete"] +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); Esc in an open paragraph leaves its text and selects its box, so Delete removes it (the unapplied draft is discarded); 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. Replacement text the paragraph's font can't show takes the craft-fonts Japanese faces when they draw all of it (Uzbek қ, Kazakh ә are in none of them), else the bundled Inter face, so those letters write in every build (#2593). Legacy non-SFNT preview, mixed-font/style contextual runs, full structure-tree preservation and author intent in untagged PDFs remain limited; Styled-path outline fallback batches by actual face with 240 glyphs per font and preserves source styles; adjacent equal-font/style characters form runs. At most 15360 distinct fallback glyphs per styled paragraph; missing glyphs are refused before fallback objects are written. Glyph coverage, mixed contextual shaping, single-style fallback and stale-font cleanup are unchanged; see docs/editing-existing-text.md" [[feature]] id = "edit.text-reflow" @@ -1902,8 +1919,8 @@ milestone = "M7" status = "shipped" commands = ["edit.edit_text"] tools = ["text_edit"] -evidence = ["crates/edit/src/tests.rs::paragraphs_move_and_rewrap_to_a_new_width", "crates/ui-egui/tests/it/editing.rs::dragging_a_paragraph_moves_it_and_its_edge_rewraps_it", "crates/ui-egui/tests/it/editing.rs::a_paragraph_rewraps_from_the_outer_half_of_its_edge_handles", "crates/ui-egui/tests/it/editing.rs::an_open_paragraph_moves_by_its_frame_and_by_the_arrow_keys", "crates/ui-egui/tests/it/editing.rs::arrow_keys_nudge_a_selected_image_instead_of_turning_the_page", "crates/automation/tests/it/automation.rs::editing_existing_text_through_tools"] -notes = "existing paragraphs: drag the box to move it, drag the handle on its left or right edge to rewrap it to a new width (upright pages), including the half of the handle outside the box, which started an area selection instead (#2276); the move is applied in page space through the paragraph's CTM, in its own q … Q; a click still opens it for typing (#40); once open, its frame grabs the box (click to select, drag to move) while the text keeps the caret, and the arrow keys nudge the selected box or image a point, Shift ten points (#1520)" +evidence = ["crates/edit/src/tests.rs::paragraphs_move_and_rewrap_to_a_new_width", "crates/edit/src/tests.rs::a_wrapped_line_rejoins_when_widened_and_a_typed_break_stays", "crates/edit/src/tests.rs::an_unmarked_full_line_rejoins_and_a_short_break_stays", "crates/edit/src/tests.rs::narrowing_then_widening_keeps_a_typed_double_space", "crates/edit/src/tests.rs::narrowing_then_widening_a_japanese_word_adds_no_space", "crates/ui-egui/tests/it/editing.rs::dragging_a_paragraph_moves_it_and_its_edge_rewraps_it", "crates/ui-egui/tests/it/editing.rs::a_paragraph_rewraps_from_the_outer_half_of_its_edge_handles", "crates/ui-egui/tests/it/editing.rs::a_quick_flick_on_either_edge_rewraps_the_paragraph", "crates/ui-egui/tests/it/editing.rs::an_open_paragraph_moves_by_its_frame_and_by_the_arrow_keys", "crates/ui-egui/tests/it/editing.rs::arrow_keys_nudge_a_selected_image_instead_of_turning_the_page", "crates/automation/tests/it/automation.rs::editing_existing_text_through_tools", "crates/automation/tests/it/automation.rs::narrowing_then_widening_keeps_a_typed_double_space_through_tools", "crates/ui-egui/tests/play.rs::play_sessions_fetch_paragraph_bugs"] +notes = "existing paragraphs: drag the box to move it, drag the handle on its left or right edge to rewrap it to a new width (upright pages), including the half of the handle outside the box, which started an area selection instead (#2276); a wrap rejoins when the box is widened, with the number of spaces that wrap consumed (a typed run stays a run; a Chinese or Japanese word split adds none) and that count survives an incremental save and a full rewrite (#2529), and a newline that was typed stays; an unmarked line that fills the measure rejoins, a short line does not; the move is applied in page space through the paragraph's CTM, in its own q … Q; a click still opens it for typing (#40); once open, its frame grabs the box (click to select, drag to move) while the text keeps the caret, and the arrow keys nudge the selected box or image a point, Shift ten points (#1520)" [[feature]] id = "edit.paragraph-detection" @@ -1914,8 +1931,8 @@ milestone = "M7" status = "shipped" commands = ["edit.edit_text"] tools = ["text_paragraphs"] -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/edit/src/tests.rs::a_wrapped_list_item_keeps_its_continuation_and_the_items_after_it", "crates/ui-egui/tests/it/editing.rs::editing_existing_text_in_place", "crates/automation/tests/it/automation.rs::editing_existing_text_through_tools"] -notes = "lines grouped into paragraphs by stream, font, size, left alignment and regular baseline spacing; hyphenated line ends are joined; a row opening with a list marker starts its own item, and the row a list item wraps onto continues it at its hanging indent, so editing one item never takes the items after it" +evidence = ["crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::a_wrapped_list_item_keeps_its_continuation_and_the_items_after_it", "crates/edit/src/tests.rs::a_blank_line_keeps_the_next_paragraph", "crates/edit/src/tests.rs::a_grown_paragraph_does_not_swallow_the_next_one", "crates/automation/tests/it/automation.rs::a_blank_line_stays_a_separate_paragraph_through_tools", "crates/automation/tests/it/automation.rs::paragraph_grouping_survives_save_and_reopen_through_tools", "crates/edit/src/tests.rs::a_partial_rewrite_of_the_only_paragraph_stays_one_block", "crates/edit/src/tests.rs::a_partial_rewrite_closes_before_the_operator_that_follows_the_paragraph", "crates/edit/src/tests.rs::a_partial_rewrite_of_the_last_marked_paragraph_stays_inside_its_region", "crates/edit/src/tests.rs::a_partial_rewrite_keeps_the_next_marked_region_closed", "crates/edit/src/tests.rs::a_partial_rewrite_of_an_unmarked_paragraph_leaves_the_marked_neighbour_closed", "crates/edit/src/tests.rs::a_partial_rewrite_inside_one_marked_region_leaves_the_neighbour_inside_it", "crates/edit/src/tests.rs::a_partial_rewrite_inside_one_text_object_keeps_the_neighbour", "crates/edit/src/tests.rs::a_partial_rewrite_of_the_last_paragraph_in_one_text_object_keeps_the_one_before_it", "crates/edit/src/tests.rs::a_partial_rewrite_of_the_middle_paragraph_in_one_text_object_keeps_both_neighbours", "crates/automation/tests/it/automation.rs::a_shared_text_object_keeps_both_paragraphs_through_tools", "crates/automation/tests/it/automation.rs::a_partial_rewrite_of_the_last_paragraph_stays_one_through_tools", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/it/editing.rs::editing_existing_text_in_place", "crates/automation/tests/it/automation.rs::editing_existing_text_through_tools"] +notes = "lines grouped into paragraphs by stream, font, size, left alignment and regular baseline spacing; a blank line (about twice the paragraph's own leading) stays a separate paragraph, and editing the first one leaves the next in place (#2517). An explicit TL is that leading. Without one, Td does not set it: three or more rows at one repeated step stay one paragraph, and a single wider gap stays a break. A rewrite marks the whole paragraph, including a row it leaves in the source, so adding a row does not absorb the next paragraph on a later edit. When the paragraph is the last text on the page, that marker closes after the paragraph's last ET, so a kept source row stays inside it. When the next paragraph shares that text object, the marker opens after BT and closes before the neighbour's text. When the rewritten paragraph is the last one in that text object, the marker opens after the preceding paragraph and closes before the shared ET. The extra marker is not added when the paragraph is already one marked region and the next line is in a different region. Hyphenated line ends are joined. A row opening with a list marker starts its own item, and the row a list item wraps onto continues it at its hanging indent, so editing one item never takes the items after it. Still open: a larger heading joined to smaller body lines (#1756) and cells in different columns (#1680, #2478). Two rows and no third step still cannot be told from a paragraph break when there is no TL." [[feature]] id = "edit.font-reuse-substitution" @@ -1926,7 +1943,7 @@ milestone = "M7" status = "shipped" commands = ["edit.edit_text"] tools = ["text_edit"] -evidence = ["crates/edit/src/tests.rs::missing_glyphs_substitute_helvetica_and_impossible_text_is_refused", "crates/edit/src/tests.rs::a_bold_stand_in_font_leaves_an_earlier_regular_one_alone", "crates/fonts/src/pdf.rs::subset_fonts_only_encode_the_glyphs_they_have", "crates/fonts/src/pdf.rs::composite_fonts_use_to_unicode"] +evidence = ["crates/edit/src/tests.rs::missing_glyphs_substitute_helvetica_and_impossible_text_is_refused", "crates/edit/src/tests.rs::a_bold_stand_in_font_leaves_an_earlier_regular_one_alone", "crates/fonts/src/pdf/tests.rs::subset_fonts_only_encode_the_glyphs_they_have", "crates/fonts/src/pdf/tests.rs::composite_fonts_use_to_unicode"] notes = "the line's font is reused when every character has a code and a glyph (ToUnicode, encodings, Differences; subset glyph checks); otherwise a standard face of the source font's family (Helvetica, Times or Courier, in its weight and slant; WinAnsi) is used and reported; text no font can show is refused. Each standard stand-in has a resource of its own face (PCEdTimesBold, ...), so a bold paragraph's stand-in no longer turns paragraphs set earlier in a regular one bold (#4280); pages already saved with the shared PCEdHelv name by older builds are not repaired" [[feature]] @@ -1938,8 +1955,8 @@ milestone = "M7" status = "shipped" commands = ["edit.edit_text", "edit.text"] tools = ["text_edit", "content_update"] -evidence = ["crates/edit/src/tests.rs::paragraphs_take_new_formatting", "crates/ui-egui/tests/it/editing.rs::the_format_panel_restyles_the_paragraph_being_edited", "crates/automation/tests/it/automation.rs::editing_existing_text_through_tools"] -notes = "existing paragraphs and added text: font family (Helvetica, Times, Courier) with bold and italic, size, colour; existing paragraphs are rewrapped" +evidence = ["crates/edit/src/tests.rs::paragraphs_take_new_formatting", "crates/ui-egui/tests/it/editing.rs::the_format_panel_restyles_the_paragraph_being_edited", "crates/automation/tests/it/automation.rs::editing_existing_text_through_tools", "crates/ui-egui/src/panels/format_text_ui.rs::typed_sizes_accept_small_fractional_values_and_reject_invalid_input", "crates/ui-egui/src/panels/format_text_ui.rs::typed_size_commits_once_and_invalid_input_preserves_the_style", "crates/ui-egui/tests/it/editing.rs::typed_small_coloured_text_size_survives_save_and_reopen"] +notes = "existing paragraphs and added text: font family (Helvetica, Times, Courier) with bold and italic, size, colour; existing paragraphs are rewrapped; typed fractional font sizes commit once; Fill & Sign text/date colour is remembered and preserved in preview and saved appearances (#2444)" [[feature]] id = "edit.text-format-style" @@ -1959,11 +1976,19 @@ title = "Superscript and subscript" area = "C" tier = "P2" milestone = "M7" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "partial" +commands = ["edit.edit_text"] +tools = ["text_script"] +evidence = [ + "crates/engine/src/text_script.rs::every_form_toggles_back_to_its_character", + "crates/engine/src/text_script.rs::text_with_characters_unicode_cannot_raise_toggles_back", + "crates/ui-egui/src/tools/edit_text_ui.rs::superscript_and_subscript_conversion", + "crates/ui-egui/tests/it/editing.rs::the_format_panel_toggles_superscript_and_subscript_on_selection", + "crates/automation/tests/it/automation.rs::superscript_and_subscript_through_tools", + "crates/engine/src/text_script.rs::mixed_plain_and_scripted_text_normalizes_instead_of_round_tripping", + "crates/automation/tests/it/automation.rs::mixed_script_tools_normalize_but_undo_restores_the_exact_mixture", +] +notes = "Toggled in the Format text panel for the selection, and headlessly with text_script on a paragraph or a byte range of it; each toggle of a paragraph is one undo step. Uses Unicode superscript and subscript forms (2 ↔ ²) rather than text rise (Ts) and font scaling, so the font must provide the corresponding glyphs and extracted text contains the Unicode characters. Characters Unicode has no form for (q, most capitals in subscript) stay unchanged and do not determine script state. Two toggles restore uniformly plain or uniformly scripted inputs. Mixed input normalizes (H₂2 → H₂₂ → H22), so a second toggle does not recover the original mixture; Undo restores the exact prior text. The mathematical minus sign U+2212 has its own script mapping, while ASCII hyphen-minus stays unchanged." [[feature]] id = "edit.paragraph-alignment" @@ -1974,8 +1999,8 @@ milestone = "M7" status = "shipped" commands = ["edit.edit_text", "edit.text"] tools = ["text_edit", "content_update"] -evidence = ["crates/edit/src/tests.rs::justify_underline_and_spacing", "crates/edit/src/tests.rs::paragraphs_take_new_formatting", "crates/edit/src/tests.rs::a_one_row_title_keeps_its_centre_when_it_grows_and_when_it_wraps", "crates/ui-egui/tests/it/editing.rs::the_format_panel_restyles_the_paragraph_being_edited"] -notes = "left, centre, right and justify (word spacing per line, last line left) for existing paragraphs and added text. A centred paragraph is found either from its ragged rows or, for a one row title, from its centre sitting on the page's own centre away from the edge the rest of the text uses, so growing or wrapping it keeps that centre (#2581)" +evidence = ["crates/edit/src/tests.rs::justify_underline_and_spacing", "crates/edit/src/tests.rs::paragraphs_take_new_formatting", "crates/edit/src/tests.rs::a_one_row_title_keeps_its_centre_when_it_grows_and_when_it_wraps", "crates/ui-egui/tests/it/editing.rs::the_format_panel_restyles_the_paragraph_being_edited", "crates/edit/src/tests.rs::one_row_of_a_paragraph_takes_its_own_alignment_is_refused_without_changing_the_document", "crates/edit/src/tests.rs::one_row_of_a_styled_region_takes_its_own_alignment_is_refused_without_changing_the_document", "crates/edit/src/tests.rs::added_text_paragraphs_keep_their_own_alignments", "crates/ui-egui/tests/it/editing.rs::selected_existing_rows_keep_alignment_disabled_and_the_complete_paragraph", "crates/ui-egui/tests/it/content.rs::aligning_a_selected_line_of_added_text_aligns_only_that_line", "crates/automation/tests/it/automation.rs::added_text_lines_take_their_own_alignments"] +notes = "Whole-paragraph left, centre and right alignment remain available for existing PDF text; the supported single-style path also justifies. Mixed-style existing-paragraph justification is explicitly refused rather than silently applying Left. Selected-row alignment of existing PDF paragraphs is withheld: the UI disables alignment with a text selection, and text_edit refuses per-row intent before mutation until persistence and regrouping can be qualified. Added text retains independently persisted per-line alignment in /PCAdded, including through page_add_text and content_update. Missing: existing whole-paragraph justification intent across reopening (#2508/#2515), mixed-style justification, and existing-row alignment persistence/regrouping. The added-text preview uses egui spacing, close to but not exactly as written. Review 5712795161 follow-up validation is pending." [[feature]] id = "edit.line-paragraph-spacing" @@ -2129,8 +2154,8 @@ milestone = "M7" status = "shipped" commands = ["edit.image"] tools = ["content_update", "image_edit"] -evidence = ["crates/ui-egui/tests/it/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/it/automation.rs::editing_page_images_through_tools", "crates/engine/src/tests.rs::added_images_rotate_flip_and_crop_as_drawn", "crates/automation/tests/it/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." +evidence = ["crates/edit/src/tests.rs::removing_a_crop_preserves_author_clips_and_works_under_outer_clip", "crates/ui-egui/tests/it/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/it/automation.rs::editing_page_images_through_tools", "crates/engine/src/tests.rs::added_images_rotate_flip_and_crop_as_drawn", "crates/automation/tests/it/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 removes only a crop created by PdfCraft, preserving original document clips. 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. Grouped Form artwork cannot be cropped. Shared clips and other clipping paths from the original file remain unchanged." [[feature]] id = "edit.image-rotate-flip" @@ -2153,8 +2178,8 @@ milestone = "M7" status = "shipped" commands = ["edit.edit_text"] tools = ["image_edit", "image_hit"] -evidence = ["crates/edit/src/tests.rs::private_image_crop_with_intervening_state_resizes_across_streams", "crates/automation/tests/it/automation.rs::private_crops_with_nested_state_resize_save_reopen_through_tools", "crates/edit/src/tests.rs::form_artwork_edits_only_the_selected_placement", "crates/ui-egui/tests/it/editing.rs::editing_existing_form_artwork_on_the_page", "crates/automation/tests/it/automation.rs::editing_form_artwork_through_tools", "crates/edit/src/tests.rs::page_images_move_turn_replace_and_delete", "crates/ui-egui/tests/it/editing.rs::editing_existing_images_on_the_page", "crates/automation/tests/it/automation.rs::editing_page_images_through_tools", "crates/edit/src/tests.rs::curved_image_clips_have_analytic_bounds_and_holes_do_not_capture_points", "crates/edit/src/tests.rs::cropped_image_resizing_moves_private_clips_and_keeps_shared_ones", "crates/ui-egui/tests/it/editing.rs::cropped_image_corner_resize_keeps_opposite_corner_and_scales_crop", "crates/edit/src/tests.rs::image_selection_bounds_respect_clips_and_restore_graphics_state", "crates/ui-egui/tests/it/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/it/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools", "crates/edit/src/tests.rs::resizing_an_image_under_a_shared_clip_moves_its_own_clip"] -notes = "existing raster images and grouped Form artwork: select, drag to move, drag a corner to resize keeping proportions; Delete removes one placement. Forms include their nested content as a group; individual internal elements and inline images are not supported. See docs/editing-images.md; added images too Since #1116: raster images and grouped Forms move/resize/turn as placements; selected grips own the gesture and opposite anchor, with no PDF writes during movement. Curved path frames use analytic Bezier extrema and hit testing evaluates actual nonzero/even-odd fill rules, excluding holes; intersections can retain a loose frame. Selection and resize use the visible (clipped) frame; exclusive image clips move with their pixels across nested states, intervening operators and content streams, also inside a clip the picture shares with other content (its own clip scales, the shared one stays); shared clips stay fixed. Inline images and Form internals remain unsupported; see docs/editing-images.md" +evidence = ["crates/edit/src/tests.rs::private_image_crop_with_intervening_state_resizes_across_streams", "crates/automation/tests/it/automation.rs::private_crops_with_nested_state_resize_save_reopen_through_tools", "crates/edit/src/tests.rs::form_artwork_edits_only_the_selected_placement", "crates/ui-egui/tests/it/editing.rs::editing_existing_form_artwork_on_the_page", "crates/automation/tests/it/automation.rs::editing_form_artwork_through_tools", "crates/edit/src/tests.rs::page_images_move_turn_replace_and_delete", "crates/ui-egui/tests/it/editing.rs::editing_existing_images_on_the_page", "crates/automation/tests/it/automation.rs::editing_page_images_through_tools", "crates/edit/src/tests.rs::curved_image_clips_have_analytic_bounds_and_holes_do_not_capture_points", "crates/edit/src/tests.rs::cropped_image_resizing_moves_private_clips_and_keeps_shared_ones", "crates/ui-egui/tests/it/editing.rs::cropped_image_corner_resize_keeps_opposite_corner_and_scales_crop", "crates/edit/src/tests.rs::image_selection_bounds_respect_clips_and_restore_graphics_state", "crates/ui-egui/tests/it/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/it/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools", "crates/edit/src/tests.rs::resizing_an_image_under_a_shared_clip_moves_its_own_clip", "crates/automation/tests/it/automation.rs::transforming_page_images_through_tools"] +notes = "existing raster images and grouped Form artwork: select, drag to move, 8-point handles to resize (corners keep proportions, edges scale along axis, Shift inverts aspect lock); Delete removes one placement. Headless: image_edit action move (a new box) or transform (any finite, non-flat 6-number matrix in top-left-origin points, applied after the placement: the edit a resize of a turned image makes). Forms include their nested content as a group; individual internal elements and inline images are not supported. See docs/editing-images.md; added images too Since #1116: raster images and grouped Forms move/resize/turn as placements; selected grips own the gesture and opposite anchor, with no PDF writes during movement. Curved path frames use analytic Bezier extrema and hit testing evaluates actual nonzero/even-odd fill rules, excluding holes; intersections can retain a loose frame. Selection and resize use the visible (clipped) frame; exclusive image clips move with their pixels across nested states, intervening operators and content streams, also inside a clip the picture shares with other content (its own clip scales, the shared one stays); shared clips stay fixed. Inline images and Form internals remain unsupported; see docs/editing-images.md" [[feature]] id = "edit.image-extract" @@ -2186,11 +2211,17 @@ title = "Arrange objects (bring forward, send backward)" area = "C" tier = "P1" milestone = "M7" -status = "planned" +status = "partial" commands = [] -tools = [] -evidence = [] -notes = "" +tools = ["image_edit"] +evidence = [ + "crates/edit/src/tests.rs::image_arrange_stacking_order_and_remove_crop", + "crates/edit/src/tests.rs::image_arrange_refuses_crossed_scope_boundaries_without_changes", + "crates/edit/src/tests.rs::arrange_refuses_unrestorable_extended_print_state", + "crates/automation/tests/it/automation.rs::arranging_page_images_through_tools", + "crates/ui-egui/tests/it/editing.rs::duplicate_xobject_at_different_rects_arrange_and_delete_keeps_other_instance_through_menu", +] +notes = "Image stacking order in content streams: Bring to Front, Send to Back, Bring Forward, Send Backward with undo/redo and persistence. Refuses arranging across shared marked content, unbalanced graphics/marked-content boundaries, or when clipping path transforms cannot be cleanly preserved. Stacking for arbitrary vector paths and inline images remains planned." [[feature]] id = "edit.align-distribute-objects" @@ -2351,7 +2382,7 @@ milestone = "M12" status = "partial" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/it/layer_actions.rs::links_set_layer_visibility", "crates/ui-egui/tests/it/layer_actions.rs::push_buttons_set_layer_visibility", "crates/engine/src/tests.rs::set_layer_actions_change_what_renders", "crates/engine/src/tests.rs::set_layer_actions_toggle_in_order_and_keep_radio_groups", "crates/render/src/inspect.rs::links_read_set_layer_actions", "crates/forms/src/tests.rs::push_buttons_read_set_layer_actions"] +evidence = ["crates/ui-egui/tests/it/layer_actions.rs::links_set_layer_visibility", "crates/ui-egui/tests/it/layer_actions.rs::push_buttons_set_layer_visibility", "crates/engine/src/tests.rs::set_layer_actions_change_what_renders", "crates/engine/src/tests.rs::set_layer_actions_toggle_in_order_and_keep_radio_groups", "crates/render/src/inspect/tests.rs::links_read_set_layer_actions", "crates/forms/src/tests.rs::push_buttons_read_set_layer_actions"] notes = "Links and push buttons run it: ON, OFF and Toggle in order, by optional content group, and with /PreserveRB (the default) a layer turned on turns off the rest of its /RBGroups group. Like the Layers panel it changes the view, not the document (no undo step, not saved). Not yet: creating the action (Link Properties, a field's Actions), bookmarks and other triggers, /Next chains, JavaScript ocg.state, and the Layers panel keeping radio-button groups itself." [[feature]] @@ -2423,8 +2454,8 @@ milestone = "M7" status = "shipped" commands = ["edit.header_footer"] tools = ["doc_header_footer"] -evidence = ["crates/ui-egui/tests/it/marks.rs::header_and_footer_dialog_inserts_tokens_and_applies_to_a_subset", "crates/edit/src/tests.rs::header_and_footer_are_drawn_in_display_space_and_wrap_the_original_content", "crates/engine/src/tests.rs::headers_footers_watermarks_and_backgrounds_show_update_and_remove", "crates/ui-egui/tests/it/marks.rs::the_preview_shows_the_page_the_range_selects_under_the_marks"] -notes = "Standard faces only: Helvetica, Times or Courier (#3754; no bold, italic or embedded fonts yet); drawn in display space on rotated pages; artifact-tagged; the dialog previews the text over the page's own thumbnail (the current page, or the first one the range selects), in the interface font and with tokens unexpanded" +evidence = ["crates/ui-egui/tests/it/marks.rs::header_and_footer_dialog_inserts_tokens_and_applies_to_a_subset", "crates/edit/src/tests.rs::header_and_footer_are_drawn_in_display_space_and_wrap_the_original_content", "crates/engine/src/tests.rs::headers_footers_watermarks_and_backgrounds_show_update_and_remove", "crates/ui-egui/tests/it/marks.rs::the_preview_shows_the_page_the_range_selects_under_the_marks", "crates/ui-egui/tests/it/marks.rs::update_previews_the_page_without_the_marks_it_replaces", "crates/engine/src/tests.rs::update_previews_on_the_page_without_the_marks_it_replaces"] +notes = "Standard faces only: Helvetica, Times or Courier (#3754; no bold, italic or embedded fonts yet); drawn in display space on rotated pages; artifact-tagged; the dialog previews the text over the page's own thumbnail (the current page, or the first one the range selects), in the interface font and with tokens unexpanded; Update previews it on the page without the headers and footers it replaces" [[feature]] id = "edit.header-footer-update-remove" @@ -2435,8 +2466,8 @@ milestone = "M7" status = "shipped" commands = ["edit.header_footer.update", "edit.header_footer.remove"] tools = ["doc_header_footer", "doc_remove_marks"] -evidence = ["crates/edit/src/tests.rs::replace_and_remove_restore_the_original_content", "crates/ui-egui/tests/it/marks.rs::header_and_footer_dialog_inserts_tokens_and_applies_to_a_subset"] -notes = "recognises PdfCraft marks; Acrobat-made headers are not detected yet" +evidence = ["crates/edit/src/tests.rs::replace_and_remove_restore_the_original_content", "crates/ui-egui/tests/it/marks.rs::header_and_footer_dialog_inserts_tokens_and_applies_to_a_subset", "crates/edit/src/mark_transaction_tests.rs::header_footer_changes_are_atomic_after_a_later_page_failure", "crates/edit/src/mark_transaction_tests.rs::replacements_commit_all_selected_pages_and_preserve_other_marks_and_shared_resources"] +notes = "Add and Update are atomic across all selected pages: errors preserve existing marks, page content/resources and pending edits (#5657). recognises PdfCraft marks; Acrobat-made headers are not detected yet" [[feature]] id = "edit.header-footer-tokens" @@ -2459,7 +2490,7 @@ milestone = "M7" status = "shipped" commands = ["edit.header_footer"] tools = ["doc_header_footer"] -evidence = ["crates/ui-egui/src/marks_ui.rs::page_ranges_with_subsets", "crates/ui-egui/tests/it/marks.rs::header_and_footer_dialog_inserts_tokens_and_applies_to_a_subset"] +evidence = ["crates/ui-egui/src/dialogs/marks_ui.rs::page_ranges_with_subsets", "crates/ui-egui/tests/it/marks.rs::header_and_footer_dialog_inserts_tokens_and_applies_to_a_subset"] notes = "all / from–to, all / even / odd" [[feature]] @@ -2483,8 +2514,8 @@ milestone = "M7" status = "shipped" commands = ["edit.watermark"] tools = ["doc_watermark"] -evidence = ["crates/edit/src/tests.rs::watermarks_rotate_fade_and_can_go_behind", "crates/ui-egui/tests/it/marks.rs::watermark_and_background_dialogs", "crates/automation/tests/it/automation.rs::headers_watermarks_and_backgrounds_through_tools", "crates/ui-egui/tests/it/marks.rs::the_preview_shows_the_page_the_range_selects_under_the_marks"] -notes = "Helvetica; fit-to-page or a size; the dialog previews it over the page's own thumbnail, always on top (a watermark behind the page is not drawn under its content there)" +evidence = ["crates/edit/src/tests.rs::watermarks_rotate_fade_and_can_go_behind", "crates/ui-egui/tests/it/marks.rs::watermark_and_background_dialogs", "crates/automation/tests/it/automation.rs::headers_watermarks_and_backgrounds_through_tools", "crates/ui-egui/tests/it/marks.rs::the_preview_shows_the_page_the_range_selects_under_the_marks", "crates/ui-egui/tests/it/marks.rs::update_previews_the_page_without_the_marks_it_replaces", "crates/engine/src/tests.rs::update_previews_on_the_page_without_the_marks_it_replaces"] +notes = "Helvetica; fit-to-page or a size; the dialog previews it over the page's own thumbnail, always on top (a watermark behind the page is not drawn under its content there); Update previews on the page without the watermarks it replaces" [[feature]] id = "edit.watermark-add-file" @@ -2507,8 +2538,8 @@ milestone = "M7" status = "shipped" commands = ["edit.watermark.update", "edit.watermark.remove"] tools = ["doc_watermark", "doc_remove_marks"] -evidence = ["crates/edit/src/tests.rs::replace_and_remove_restore_the_original_content", "crates/engine/src/tests.rs::headers_footers_watermarks_and_backgrounds_show_update_and_remove"] -notes = "" +evidence = ["crates/edit/src/tests.rs::replace_and_remove_restore_the_original_content", "crates/engine/src/tests.rs::headers_footers_watermarks_and_backgrounds_show_update_and_remove", "crates/edit/src/mark_transaction_tests.rs::watermark_changes_are_atomic_after_a_later_page_failure", "crates/edit/src/mark_transaction_tests.rs::replacements_commit_all_selected_pages_and_preserve_other_marks_and_shared_resources"] +notes = "Add and Update are atomic across all selected pages: errors preserve existing marks, page content/resources and pending edits (#5657). " [[feature]] id = "edit.watermark-appearance" @@ -2519,7 +2550,7 @@ milestone = "M7" status = "partial" commands = ["edit.watermark"] tools = ["doc_watermark"] -evidence = ["crates/edit/src/tests.rs::watermarks_rotate_fade_and_can_go_behind", "crates/edit/src/tests.rs::watermark_offset_moves_the_centre_right_and_up", "crates/edit/src/tests.rs::a_watermark_offset_that_is_not_finite_is_refused", "crates/automation/tests/it/automation.rs::watermarks_move_from_the_page_centre_through_tools", "crates/ui-egui/src/marks_ui.rs::the_watermark_edit_carries_the_offset"] +evidence = ["crates/edit/src/tests.rs::watermarks_rotate_fade_and_can_go_behind", "crates/edit/src/tests.rs::watermark_offset_moves_the_centre_right_and_up", "crates/edit/src/tests.rs::a_watermark_offset_that_is_not_finite_is_refused", "crates/automation/tests/it/automation.rs::watermarks_move_from_the_page_centre_through_tools", "crates/ui-egui/src/dialogs/marks_ui.rs::the_watermark_edit_carries_the_offset"] notes = "rotation, opacity, behind/on top, and a horizontal/vertical offset from the page centre in points (dialog and offset_x/offset_y); no offset from the page edge or in page units, and no print/screen-only variants yet" [[feature]] @@ -2530,9 +2561,9 @@ tier = "P0" milestone = "M7" status = "shipped" commands = ["edit.background"] -tools = ["doc_background"] -evidence = ["crates/ui-egui/tests/it/marks.rs::watermark_and_background_dialogs", "crates/engine/src/tests.rs::headers_footers_watermarks_and_backgrounds_show_update_and_remove", "crates/engine/src/tests.rs::backgrounds_and_watermarks_from_files", "crates/ui-egui/tests/it/marks.rs::backgrounds_and_watermarks_from_a_file", "crates/organize/src/tests.rs::a_page_becomes_a_form_xobject_upright", "crates/ui-egui/tests/it/marks.rs::the_preview_shows_the_page_the_range_selects_under_the_marks"] -notes = "colour, or a picture from a file (an image or a page of a PDF) scaled relative to the page; the dialog previews a colour as a tint of the page's own thumbnail (opaque pictures on the page are tinted there too, not in the result), a file as its frame and name" +tools = ["doc_background", "doc_backdrops"] +evidence = ["crates/ui-egui/tests/it/marks.rs::watermark_and_background_dialogs", "crates/engine/src/tests.rs::headers_footers_watermarks_and_backgrounds_show_update_and_remove", "crates/engine/src/tests.rs::backgrounds_and_watermarks_from_files", "crates/ui-egui/tests/it/marks.rs::backgrounds_and_watermarks_from_a_file", "crates/organize/src/tests.rs::a_page_becomes_a_form_xobject_upright", "crates/automation/tests/it/backdrop.rs::a_background_shows_on_a_page_that_paints_its_own_white_backdrop", "crates/ui-egui/tests/it/marks.rs::the_background_dialog_offers_to_replace_a_white_backdrop_and_warns_about_scans", "crates/ui-egui/tests/it/marks.rs::the_background_preview_follows_the_replace_checkbox", "crates/edit/src/backdrop_tests.rs::content_that_is_not_a_page_backdrop_is_left_alone", "crates/ui-egui/tests/it/marks.rs::the_preview_shows_the_page_the_range_selects_under_the_marks", "crates/ui-egui/tests/it/marks.rs::update_previews_the_page_without_the_marks_it_replaces", "crates/engine/src/tests.rs::update_previews_on_the_page_without_the_marks_it_replaces"] +notes = "colour, or a picture from a file (an image or a page of a PDF) scaled relative to the page. A page that paints its own opaque white backdrop (browsers' Save as PDF, print drivers) used to hide the background entirely; the dialog now offers to replace that fill (on by default; Remove turns it back on, after edits and redactions too, unless the operators before it changed or it would no longer be painted first, and then says which pages keep it off). The replaced copy's tag holds no page content or reference, so text a redaction or Remove Hidden Information takes off such a page leaves the file. Our own choice: Acrobat's behaviour on such pages has not been observed. Still hidden: scanned pages (warned), backdrops drawn inside a form XObject, by a non-rectangular path, or in a named colour space, and white fills drawn translucent, through a soft mask, a non-Normal blend mode or a transfer function (TR/TR2) as the graphics state stands after every gs, which are real content and stay. The dialog previews a colour as a tint of the page's own thumbnail (opaque pictures on the page are tinted there too, not in the result), a file as its frame and name; a page that will still hide the background (a scan, or a white backdrop with Replace unticked) previews without the colour; Update previews on the page without the backgrounds it replaces, not tinting the old one" [[feature]] id = "edit.background-update-remove" @@ -2543,8 +2574,8 @@ milestone = "M7" status = "shipped" commands = ["edit.background.update", "edit.background.remove"] tools = ["doc_background", "doc_remove_marks"] -evidence = ["crates/edit/src/tests.rs::replace_and_remove_restore_the_original_content", "crates/engine/src/tests.rs::headers_footers_watermarks_and_backgrounds_show_update_and_remove"] -notes = "" +evidence = ["crates/edit/src/tests.rs::replace_and_remove_restore_the_original_content", "crates/engine/src/tests.rs::headers_footers_watermarks_and_backgrounds_show_update_and_remove", "crates/edit/src/backdrop_tests.rs::a_white_fill_backdrop_is_replaced_and_put_back_when_the_background_goes", "crates/edit/src/backdrop_tests.rs::an_edit_after_the_fill_is_kept_and_the_backdrop_still_comes_back", "crates/edit/src/backdrop_tests.rs::a_copy_written_out_again_with_other_spacing_still_comes_back", "crates/edit/src/backdrop_tests.rs::a_change_before_the_fill_leaves_it_off_and_names_the_page", "crates/edit/src/backdrop_tests.rs::a_fill_that_would_cover_content_painted_before_it_stays_off", "crates/engine/src/tests.rs::remove_background_names_the_pages_whose_own_backdrop_stays_off", "crates/automation/tests/it/backdrop.rs::removed_text_leaves_the_file_when_a_background_replaced_the_pages_backdrop", "crates/edit/src/mark_transaction_tests.rs::background_changes_are_atomic_after_a_later_page_failure", "crates/edit/src/mark_transaction_tests.rs::replacements_commit_all_selected_pages_and_preserve_other_marks_and_shared_resources"] +notes = "Add and Update are atomic across all selected pages: errors preserve existing marks, page content/resources and pending edits (#5657). Remove also turns back on a page backdrop Add replaced, keeping edits made after it; where that isn't safe it says which pages keep it off" [[feature]] id = "edit.decoration-profiles" @@ -2576,11 +2607,11 @@ title = "Bates numbering across multiple files" area = "C" tier = "P1" milestone = "M7" -status = "planned" +status = "partial" commands = [] -tools = [] -evidence = [] -notes = "" +tools = ["doc_bates_multi"] +evidence = ["crates/automation/tests/it/automation.rs::bates_numbers_continue_across_files_and_report_preflight_and_permission_failures"] +notes = "The headless batch tool allocates inclusive intervals in input order and reports per-file failures; it refuses existing output paths. Missing: a GUI batch dialog (Acrobat sets this up from Header & Footer ▸ Add Bates numbering with a file list), so it is reachable only through `doc_bates_multi`." [[feature]] id = "edit.bates-remove" @@ -2656,7 +2687,7 @@ status = "shipped" commands = ["page.organize"] tools = [] evidence = ["crates/ui-egui/tests/it/editing.rs::shift_click_selects_a_range_and_moves_it", "crates/ui-egui/tests/it/editing.rs::organize_tool_opens_the_overview_directly_and_can_be_reentered", "crates/ui-egui/tests/it/editing.rs::organize_opens_on_the_current_page", "crates/ui-egui/tests/it/control.rs::organize_overview_is_reachable_through_the_control_channel", "crates/ui-egui/tests/it/editing.rs::organize_panel_comes_with_the_grid_and_closes_it", "crates/ui-egui/tests/it/editing.rs::leaving_organize_goes_back_to_the_workspace_panel"] -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. 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." +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. 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, Page labels and Page properties (the selected pages' tab order). 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 and Page templates are listed but planned." [[feature]] id = "organize.multi-select-pages" @@ -2847,7 +2878,7 @@ milestone = "M4" status = "shipped" commands = ["page.replace"] tools = ["page_replace"] -evidence = ["crates/organize/src/tests.rs::replace_pages_swaps_content_but_keeps_annotations", "crates/ui-egui/tests/it/editing.rs::replace_pages_dialog_swaps_page_content", "crates/ui-egui/tests/it/editing.rs::replace_button_replaces_selected_pages_in_organize", "crates/automation/tests/it/automation.rs::replacing_pages_through_tools", "crates/ui-egui/src/canvas.rs::replacing_a_page_after_layout_never_uploads_an_oversized_texture", "crates/ui-egui/src/canvas.rs::oversized_raster_results_do_not_replace_cached_textures"] +evidence = ["crates/organize/src/tests.rs::replace_pages_swaps_content_but_keeps_annotations", "crates/ui-egui/tests/it/editing.rs::replace_pages_dialog_swaps_page_content", "crates/ui-egui/tests/it/editing.rs::replace_button_replaces_selected_pages_in_organize", "crates/automation/tests/it/automation.rs::replacing_pages_through_tools", "crates/ui-egui/src/canvas/tests.rs::replacing_a_page_after_layout_never_uploads_an_oversized_texture", "crates/ui-egui/src/canvas/tests.rs::oversized_raster_results_do_not_replace_cached_textures"] notes = "Content, resources and boxes replaced; links, comments, fields and bookmarks kept. Page-list edits discard demand laid out against the previous geometry; the next frame plans bounded backdrops and tiles. Raster uploads reject empty images and dimensions beyond the current GPU texture limit before replacing cached textures (#3149). This covers the viewer's page, tile, thumbnail, organize and print-preview result paths; it does not change the renderer's output resolution or add a global GPU memory budget." [[feature]] @@ -3051,7 +3082,7 @@ milestone = "M4" status = "shipped" commands = ["page.combine"] tools = ["doc_combine"] -evidence = ["crates/ui-egui/tests/it/combine_grid.rs::combine_shows_a_grid_by_default_and_the_view_is_kept_in_the_settings", "crates/ui-egui/tests/it/combine_grid.rs::cards_select_like_the_list_and_the_selection_survives_switching_views", "crates/ui-egui/tests/it/combine_grid.rs::dragging_a_card_moves_it_and_undo_puts_it_back", "crates/ui-egui/tests/it/combine_grid.rs::a_dragged_selection_moves_together_to_the_first_and_last_gaps", "crates/ui-egui/tests/it/combine_grid.rs::thumbnails_show_the_first_page_each_file_takes", "crates/ui-egui/tests/it/combine_grid.rs::many_files_render_only_what_is_in_view_a_few_at_a_time", "crates/ui-egui/tests/it/combine_grid.rs::long_thin_pages_get_thumbnails_within_the_size_limits", "crates/ui-egui/tests/it/combine_grid.rs::combine_follows_the_order_shown_in_the_grid", "crates/ui-egui/src/combine_grid.rs::render_threads_never_exceed_three_while_files_come_and_go", "crates/ui-egui/src/combine_grid.rs::textures_over_budget_go_longest_unseen_first_and_those_in_view_stay", "crates/ui-egui/src/combine_grid.rs::on_the_web_one_thumbnail_waits_at_a_time", "crates/ui-egui/tests/it/combine_grid.rs::a_file_whose_pages_cant_be_measured_says_there_is_no_preview", "crates/ui-egui/tests/it/combine_grid.rs::the_toolbar_sizes_the_cards_within_limits_and_the_size_is_kept_in_the_settings", "crates/ui-egui/src/combine_grid.rs::the_cards_zoom_far_enough_to_read_a_value", "crates/ui-egui/tests/it/combine_grid.rs::ctrl_keys_and_ctrl_wheel_size_the_cards_and_leave_the_window_alone", "crates/ui-egui/tests/it/combine_grid.rs::zooming_keeps_the_card_under_the_pointer_in_place_and_thumbnails_never_go_blank", "crates/ui-egui/tests/it/combine_grid.rs::zooming_quickly_never_runs_more_than_three_render_threads", "crates/ui-egui/tests/it/combine_grid.rs::hovering_a_card_shows_its_trash_and_magnifier_and_only_then", "crates/ui-egui/tests/it/combine_grid.rs::the_card_trash_removes_only_that_file_and_undo_brings_it_back", "crates/ui-egui/tests/it/combine_grid.rs::the_magnifier_shows_the_file_large_and_flips_through_the_pages_it_adds", "crates/ui-egui/tests/it/combine_grid.rs::the_preview_closes_when_its_file_leaves_and_keeps_to_three_render_threads", "crates/ui-egui/tests/it/combine_grid.rs::a_right_click_on_a_card_offers_preview_and_remove", "crates/ui-egui/tests/it/combine_grid.rs::space_shows_the_selected_file_large_in_the_grid_and_the_list", "crates/ui-egui/tests/it/combine_grid.rs::expanding_a_file_shows_the_pages_it_adds_in_order_with_their_numbers", "crates/ui-egui/tests/it/combine_grid.rs::expand_all_and_collapse_all_from_the_toolbar_and_a_right_click", "crates/ui-egui/tests/it/combine_grid.rs::expanding_is_a_view_kept_across_views_and_zoom_and_never_an_undo_step", "crates/ui-egui/tests/it/combine_grid.rs::dragging_a_page_moves_that_page_even_between_another_files_pages", "crates/ui-egui/tests/it/combine_grid.rs::combining_with_files_expanded_gives_the_same_document", "crates/ui-egui/tests/it/combine_grid.rs::a_page_click_selects_that_page_and_the_arrows_step_rows_of_cards", "crates/ui-egui/tests/it/combine_grid.rs::a_page_cards_magnifier_opens_the_preview_on_that_page", "crates/ui-egui/tests/it/combine_grid.rs::a_long_file_expanded_renders_only_its_pages_in_view_within_three_threads", "crates/ui-egui/tests/it/combine_grid.rs::collapsing_brings_the_files_card_into_view", "crates/ui-egui/tests/it/combine_grid.rs::a_file_too_long_to_spread_out_offers_no_expand", "crates/ui-egui/src/combine_grid.rs::the_next_page_of_a_file_reuses_its_open_document_until_it_closes", "crates/ui-egui/tests/it/combine_grid.rs::removing_pages_from_the_hover_bar_the_toolbar_and_delete_never_touches_the_file", "crates/ui-egui/tests/it/combine_grid.rs::a_split_file_counts_once_and_combines_with_one_bookmark", "crates/ui-egui/src/combine_ui.rs::a_page_moved_between_another_files_pages_splits_its_file_and_parts_rejoin", "crates/ui-egui/src/combine_ui.rs::removing_pages_drops_emptied_parts_and_joins_what_meets", "crates/ui-egui/src/combine_ui.rs::page_lists_become_the_shortest_range", "crates/ui-egui/tests/it/combine_grid.rs::pages_picked_out_of_sight_or_before_an_undo_are_let_go_of", "crates/ui-egui/tests/it/combine_grid.rs::a_files_parts_join_when_what_was_between_them_goes_and_one_file_cannot_be_combined", "crates/organize/src/tests.rs::a_page_shown_twice_gets_its_own_annotations", "crates/engine/src/tests.rs::a_page_with_a_form_field_shown_twice_keeps_one_field", "crates/ui-egui/tests/it/combine_grid.rs::files_picked_by_automation_let_go_of_the_pages_picked"] +evidence = ["crates/ui-egui/tests/it/combine_grid.rs::combine_shows_a_grid_by_default_and_the_view_is_kept_in_the_settings", "crates/ui-egui/tests/it/combine_grid.rs::cards_select_like_the_list_and_the_selection_survives_switching_views", "crates/ui-egui/tests/it/combine_grid.rs::dragging_a_card_moves_it_and_undo_puts_it_back", "crates/ui-egui/tests/it/combine_grid.rs::a_dragged_selection_moves_together_to_the_first_and_last_gaps", "crates/ui-egui/tests/it/combine_grid.rs::thumbnails_show_the_first_page_each_file_takes", "crates/ui-egui/tests/it/combine_grid.rs::many_files_render_only_what_is_in_view_a_few_at_a_time", "crates/ui-egui/tests/it/combine_grid.rs::long_thin_pages_get_thumbnails_within_the_size_limits", "crates/ui-egui/tests/it/combine_grid.rs::combine_follows_the_order_shown_in_the_grid", "crates/ui-egui/src/workflows/combine_grid/tests.rs::render_threads_never_exceed_three_while_files_come_and_go", "crates/ui-egui/src/workflows/combine_grid/tests.rs::textures_over_budget_go_longest_unseen_first_and_those_in_view_stay", "crates/ui-egui/src/workflows/combine_grid/tests.rs::on_the_web_one_thumbnail_waits_at_a_time", "crates/ui-egui/tests/it/combine_grid.rs::a_file_whose_pages_cant_be_measured_says_there_is_no_preview", "crates/ui-egui/tests/it/combine_grid.rs::the_toolbar_sizes_the_cards_within_limits_and_the_size_is_kept_in_the_settings", "crates/ui-egui/src/workflows/combine_grid/tests.rs::the_cards_zoom_far_enough_to_read_a_value", "crates/ui-egui/tests/it/combine_grid.rs::ctrl_keys_and_ctrl_wheel_size_the_cards_and_leave_the_window_alone", "crates/ui-egui/tests/it/combine_grid.rs::zooming_keeps_the_card_under_the_pointer_in_place_and_thumbnails_never_go_blank", "crates/ui-egui/tests/it/combine_grid.rs::zooming_quickly_never_runs_more_than_three_render_threads", "crates/ui-egui/tests/it/combine_grid.rs::hovering_a_card_shows_its_trash_and_magnifier_and_only_then", "crates/ui-egui/tests/it/combine_grid.rs::the_card_trash_removes_only_that_file_and_undo_brings_it_back", "crates/ui-egui/tests/it/combine_grid.rs::the_magnifier_shows_the_file_large_and_flips_through_the_pages_it_adds", "crates/ui-egui/tests/it/combine_grid.rs::the_preview_closes_when_its_file_leaves_and_keeps_to_three_render_threads", "crates/ui-egui/tests/it/combine_grid.rs::a_right_click_on_a_card_offers_preview_and_remove", "crates/ui-egui/tests/it/combine_grid.rs::space_shows_the_selected_file_large_in_the_grid_and_the_list", "crates/ui-egui/tests/it/combine_grid.rs::expanding_a_file_shows_the_pages_it_adds_in_order_with_their_numbers", "crates/ui-egui/tests/it/combine_grid.rs::expand_all_and_collapse_all_from_the_toolbar_and_a_right_click", "crates/ui-egui/tests/it/combine_grid.rs::expanding_is_a_view_kept_across_views_and_zoom_and_never_an_undo_step", "crates/ui-egui/tests/it/combine_grid.rs::dragging_a_page_moves_that_page_even_between_another_files_pages", "crates/ui-egui/tests/it/combine_grid.rs::combining_with_files_expanded_gives_the_same_document", "crates/ui-egui/tests/it/combine_grid.rs::a_page_click_selects_that_page_and_the_arrows_step_rows_of_cards", "crates/ui-egui/tests/it/combine_grid.rs::a_page_cards_magnifier_opens_the_preview_on_that_page", "crates/ui-egui/tests/it/combine_grid.rs::a_long_file_expanded_renders_only_its_pages_in_view_within_three_threads", "crates/ui-egui/tests/it/combine_grid.rs::collapsing_brings_the_files_card_into_view", "crates/ui-egui/tests/it/combine_grid.rs::a_file_too_long_to_spread_out_offers_no_expand", "crates/ui-egui/src/workflows/combine_grid/tests.rs::the_next_page_of_a_file_reuses_its_open_document_until_it_closes", "crates/ui-egui/tests/it/combine_grid.rs::removing_pages_from_the_hover_bar_the_toolbar_and_delete_never_touches_the_file", "crates/ui-egui/tests/it/combine_grid.rs::a_split_file_counts_once_and_combines_with_one_bookmark", "crates/ui-egui/src/workflows/combine_ui/tests.rs::a_page_moved_between_another_files_pages_splits_its_file_and_parts_rejoin", "crates/ui-egui/src/workflows/combine_ui/tests.rs::removing_pages_drops_emptied_parts_and_joins_what_meets", "crates/ui-egui/src/workflows/combine_ui/tests.rs::page_lists_become_the_shortest_range", "crates/ui-egui/tests/it/combine_grid.rs::pages_picked_out_of_sight_or_before_an_undo_are_let_go_of", "crates/ui-egui/tests/it/combine_grid.rs::a_files_parts_join_when_what_was_between_them_goes_and_one_file_cannot_be_combined", "crates/organize/src/tests.rs::a_page_shown_twice_gets_its_own_annotations", "crates/engine/src/tests.rs::a_page_with_a_form_field_shown_twice_keeps_one_field", "crates/ui-egui/tests/it/combine_grid.rs::files_picked_by_automation_let_go_of_the_pages_picked"] notes = "Combine files shows a card per file (the default; Grid/List toggle kept in the settings, --combine-view grid|list): the first page the file contributes (its page range's first page), stacked when it contributes more, and its name shortened in the middle. Cards are sized from 60% to 500% (toolbar slider, buttons and percentage reset, Ctrl/⌘ +/−/0, Ctrl/⌘ with the wheel or a pinch over the grid; the buttons step 60/80/100/125/150/175/200/250/300/400/500%, kept in the settings, --combine-zoom 60..500), keeping the card under the pointer in place. 200% used to be the ceiling, which was too small to read values off an invoice while reordering the pages of a statement (#2531). Hovering a card shows its own actions along the top of its page: remove that file only (whatever else is selected; undoable) and a magnifier that shows the file large, flipping through the pages it adds (buttons, Left/Right; Esc, close or a click outside closes it), also on the card's right-click menu, and Space shows the selected file large (the way there by keyboard). Cards select (click, Ctrl, Shift, arrows by card and by row), drag to reorder (one undo step; the grid scrolls at its edges) and share the list's toolbar. Thumbnails render off the UI thread, only for cards in view, sized to both sides of the page and at most 1536 px (room for a Letter page at 500% on a 1.5 pixels-per-point screen); at most three render threads at once, counted as threads until they exit (no watchdog replacements; a page stuck for good in one step that can't be interrupted keeps its thread, and so its place, for the rest of the run); textures out of view are dropped over a 32 MB budget (those in view are kept). Locked, unreadable and bad-range files show why instead of a page, and a file whose page size can't be read (e.g. opened with an RC4 owner password) says No preview; page ranges are edited in the list. On the web a thumbnail, and the page shown large, render on the UI thread (one per frame, and only one waits at a time), so a very slow or very large page stalls that frame. A file that adds more than one page (up to 2000) expands into its pages (hover bar, right-click, or Expand all / Collapse all in the toolbar): one card per page it adds, in its range's order, each saying Page N and the file's name, on a tinted band; a click on a page selects that page, Up/Down step rows of cards, dragging a file's card moves the whole file (a drop among a file's pages goes to its nearer end), and its magnifier opens the preview on that page; expanding is a view, not an undo step, and changes nothing in the combined file; a page's render reuses its file's open document for a moment rather than reading the file again. With a file expanded, single pages are selected (click, Ctrl, Shift), dragged anywhere (a page moved between another file's pages splits its file into parts, shown as rows in the list with their own page ranges; parts side by side join again) and removed (a page card's trash, the toolbar, Delete), each one undo step, never changing the files themselves; a split file still counts and combines as one file." [[feature]] @@ -3231,7 +3262,7 @@ milestone = "M4" status = "partial" commands = [] tools = ["doc_info", "page_transition_set"] -evidence = ["crates/render/src/inspect.rs::page_transitions_and_display_times_are_read", "crates/automation/tests/it/automation.rs::doc_info_lists_page_transitions_and_display_times", "crates/organize/src/tests.rs::page_transitions_are_set_removed_and_checked", "crates/automation/tests/it/automation.rs::page_transitions_are_set_and_removed_through_the_tools"] +evidence = ["crates/render/src/inspect/tests.rs::page_transitions_and_display_times_are_read", "crates/automation/tests/it/automation.rs::doc_info_lists_page_transitions_and_display_times", "crates/organize/src/tests.rs::page_transitions_are_set_removed_and_checked", "crates/automation/tests/it/automation.rs::page_transitions_are_set_and_removed_through_the_tools"] 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]] @@ -3240,11 +3271,11 @@ title = "Page tab order property" area = "D" tier = "P1" milestone = "M6" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["page.properties"] +tools = ["form_tab_order"] +evidence = ["crates/ui-egui/tests/it/editing.rs::page_properties_sets_the_tab_order_of_the_selected_pages", "crates/forms/src/tests.rs::tab_orders_are_read_for_each_page", "crates/forms/src/tests.rs::rows_and_columns_tab_right_to_left_in_a_document_bound_on_the_right", "crates/forms/src/tests.rs::an_indirect_tabs_value_orders_the_page", "crates/automation/tests/it/automation.rs::page_tab_orders_are_read_and_set_per_page_through_tools"] +notes = "Organize pages ▸ Page properties ▸ Tab Order sets the selected pages' order: Use Row Order, Use Column Order, Use Document Structure or Unspecified, as one undo step. It opens on the pages' order, or with no choice when they differ. Rows and columns run right to left in a document bound on the right. form_tab_order reads each page's order (page_tabs) and sets it for chosen pages. Acrobat's Page Properties also has an Actions tab (organize.page-actions), still planned." [[feature]] id = "organize.page-actions" @@ -3339,7 +3370,7 @@ milestone = "M4" status = "shipped" commands = [] tools = ["bookmark_list", "bookmark_set_style"] -evidence = ["crates/render/src/inspect.rs::outline_items_carry_their_colour_and_style", "crates/organize/src/tests.rs::bookmarks_report_their_colour_and_style", "crates/organize/src/tests.rs::bookmark_style_and_colour_are_set_as_the_table_says", "crates/ui-egui/src/panels.rs::bookmark_titles_take_their_style_and_colour", "crates/ui-egui/src/theme.rs::a_document_colour_is_kept_where_it_reads", "crates/ui-egui/tests/it/bookmarks.rs::style_and_colour_from_the_bookmark_menu", "crates/automation/tests/it/automation.rs::bookmark_list_reports_colour_and_style"] +evidence = ["crates/render/src/inspect/tests.rs::outline_items_carry_their_colour_and_style", "crates/organize/src/tests.rs::bookmarks_report_their_colour_and_style", "crates/organize/src/tests.rs::bookmark_style_and_colour_are_set_as_the_table_says", "crates/ui-egui/src/panels/bookmarks.rs::bookmark_titles_take_their_style_and_colour", "crates/ui-egui/src/theme/tests.rs::a_document_colour_is_kept_where_it_reads", "crates/ui-egui/tests/it/bookmarks.rs::style_and_colour_from_the_bookmark_menu", "crates/automation/tests/it/automation.rs::bookmark_list_reports_colour_and_style"] notes = "#3193: the Bookmarks panel draws each title in its /C colour and /F style (ISO 32000-2 §12.3.3, Tables 151 and 152: bit 1 italic, bit 2 bold), lightening a colour too dark for the dark theme until it reads (3:1). A bookmark's menu has Style (Bold, Italic) and Colour (the comment swatches and Other colour…); bookmark_set_style does the same and bookmark_list reports them. Regular removes /F, black removes /C, and /F bits the table doesn't define are kept. Not yet: one change for several selected bookmarks, and a Properties dialog." [[feature]] @@ -3378,6 +3409,18 @@ tools = ["bookmark_from_structure"] evidence = ["crates/a11y/src/tests.rs::headings_are_found_with_their_levels_titles_and_pages", "crates/a11y/src/tests.rs::headings_drawn_glyph_by_glyph_keep_their_words", "crates/a11y/src/tests.rs::horizontally_scaled_wrapped_heading_keeps_a_space_between_lines", "crates/organize/src/tests.rs::a_bookmark_tree_nests_entries_by_level_under_a_new_first_bookmark", "crates/engine/src/tests.rs::bookmarks_from_structure_nest_the_headings_and_undo", "crates/automation/tests/it/automation.rs::bookmarks_from_structure_through_tools", "crates/ui-egui/tests/it/bookmarks.rs::new_bookmarks_from_structure_nest_the_tagged_headings"] notes = "Bookmark options ▸ New bookmarks from structure: tagged headings (H1–H6 through the role map; a plain H takes its Sect/Part/Art nesting) become bookmarks nested by level under a new first \"Untitled\" bookmark, each linked to its element (/SE). Titles come from /ActualText, the marked text drawn directly on the page (not inside Form XObjects; words split where the glyphs are apart, so text placed glyph by glyph, as Chromium writes it, keeps its words; vertical line breaks use the absolute font size independently of Tz), /Alt or /T. Not yet: Acrobat's choice of element types (only headings), and destinations go to the heading's page rather than its position on it" +[[feature]] +id = "organize.bookmarks-from-text-styles" +title = "Generate bookmarks from text styles" +area = "D" +tier = "P2" +milestone = "M4" +status = "shipped" +commands = ["bookmark.from_text_styles"] +tools = ["text_styles", "bookmark_from_text_styles"] +evidence = ["crates/engine/src/tests.rs::bookmarks_from_text_styles_nest_the_headings_and_undo", "crates/engine/src/text_style_bookmarks.rs::subset_tags_are_left_out_of_the_font_name", "crates/engine/src/text_style_bookmarks.rs::styles_match_within_a_quarter_point", "crates/automation/tests/it/automation.rs::bookmarks_from_text_styles_through_tools", "crates/ui-egui/tests/it/bookmarks.rs::new_bookmarks_from_text_styles_from_the_bookmark_options"] +notes = "Bookmark options (and Pages) ▸ New bookmarks from text styles…, for documents whose headings aren't tagged (#1693): the dialog lists the document's text styles (font without its subset tag, the size shown at, bold, italic, line count and a sample), the largest first and offered for level 1; one style per level, up to four levels (nine headlessly). Every line in a level's style becomes a bookmark of that level under a new first \"Untitled\" bookmark, a heading set on several rows is one bookmark, and each goes to the top of its heading (/XYZ). Lines are read down each page, then left to right, so a multi-column page's headings are taken row by row across the columns; sizes within a quarter point match. Not yet: several styles for one level, colour as part of a style, and the first 5000 pages only. Acrobat has no such command (its bookmarks come from structure); this is beyond it" + [[feature]] id = "organize.attachments-add" title = "Add file attachments" @@ -3495,7 +3538,7 @@ milestone = "M3" status = "shipped" commands = ["file.properties"] tools = ["doc_info"] -evidence = ["crates/ui-egui/tests/it/view.rs::fonts_tab_lists_fonts_and_embedding", "crates/render/src/inspect.rs::doc_info_says_which_face_stands_in_and_why", "crates/render/src/inspect.rs::an_embedded_font_reports_no_substitution_at_all"] +evidence = ["crates/ui-egui/tests/it/view.rs::fonts_tab_lists_fonts_and_embedding", "crates/render/src/inspect/resources.rs::doc_info_says_which_face_stands_in_and_why", "crates/render/src/inspect/resources.rs::an_embedded_font_reports_no_substitution_at_all"] notes = "name, type, embedding, subset and encoding; doc_info also reports the face standing in for a program the document does not embed and why it was chosen (Acrobat's Actual Font), including the case where no licensed face exists so nothing is drawn. Still missing: the Fonts tab in the interface does not show the substituted face yet, only the data layer does" [[feature]] @@ -3507,7 +3550,7 @@ milestone = "M4" status = "shipped" commands = ["file.properties"] tools = ["doc_initial_view"] -evidence = ["crates/organize/src/view.rs::initial_view_resolves_indirect_boolean_preferences", "crates/ui-egui/tests/it/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/it/viewer.rs::initial_view_is_edited_and_honoured_on_open", "crates/automation/tests/it/automation.rs::initial_view_through_tools", "crates/render/src/inspect.rs::a_go_to_action_opens_at_its_destination_explicit_or_named", "crates/ui-egui/tests/it/open_action.rs::an_xyz_open_action_puts_its_point_at_the_window_top_left", "crates/ui-egui/tests/it/open_action.rs::a_fith_open_action_puts_its_top_at_the_window_top", "crates/ui-egui/tests/it/open_action.rs::a_named_open_destination_opens_its_page", "crates/ui-egui/tests/it/open_action.rs::a_full_screen_document_shows_its_non_full_screen_panel_outside_full_screen", "crates/organize/src/tests.rs::an_unchanged_opening_destination_keeps_its_position_and_name", "crates/automation/tests/it/automation.rs::doc_info_reports_the_open_destination", "crates/organize/src/tests.rs::choosing_page_one_replaces_a_named_opening_destination", "crates/organize/src/tests.rs::a_named_opening_destination_is_kept_while_it_still_holds"] +evidence = ["crates/organize/src/view.rs::initial_view_resolves_indirect_boolean_preferences", "crates/ui-egui/tests/it/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/it/viewer.rs::initial_view_is_edited_and_honoured_on_open", "crates/automation/tests/it/automation.rs::initial_view_through_tools", "crates/render/src/inspect/navigation.rs::a_go_to_action_opens_at_its_destination_explicit_or_named", "crates/ui-egui/tests/it/open_action.rs::an_xyz_open_action_puts_its_point_at_the_window_top_left", "crates/ui-egui/tests/it/open_action.rs::a_fith_open_action_puts_its_top_at_the_window_top", "crates/ui-egui/tests/it/open_action.rs::a_named_open_destination_opens_its_page", "crates/ui-egui/tests/it/open_action.rs::a_full_screen_document_shows_its_non_full_screen_panel_outside_full_screen", "crates/organize/src/tests.rs::an_unchanged_opening_destination_keeps_its_position_and_name", "crates/automation/tests/it/automation.rs::doc_info_reports_the_open_destination", "crates/organize/src/tests.rs::choosing_page_one_replaces_a_named_opening_destination", "crates/organize/src/tests.rs::a_named_opening_destination_is_kept_while_it_still_holds"] 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. Window and interface options also read indirect boolean ViewerPreferences values. Missing: Acrobat's Full Screen preference to turn that alert off and its \"remember my choice for this document\" (misc.prefs-full-screen). On open the /OpenAction destination places the view as ISO 32000-2 Table 149 says (the /XYZ point at the window's top-left, /FitH and /FitV top or left at the edge), and a named destination (name or string, /Names /Dests tree or catalog /Dests, §12.3.2.4) resolves; Always use my default zoom keeps only the page. A full-screen document shows its /NonFullScreenPageMode panel outside full screen (Table 147). doc_info reports the opening destination. Document Properties keeps an opening action whose page and magnification are left alone, but still edits only those two; it shows a named destination's page and magnification, and choosing another page or magnification replaces the name with an explicit destination, except that page 1 at the default magnification removes /OpenAction so the first page opens. A name that resolves nowhere reads as page 1 and is kept until another page is chosen" [[feature]] @@ -3519,7 +3562,7 @@ milestone = "M4" status = "partial" commands = ["file.properties"] tools = ["doc_info", "doc_set_info"] -evidence = ["crates/organize/src/tests.rs::custom_document_properties_are_listed_set_removed_and_their_names_checked", "crates/render/src/inspect.rs::custom_document_properties_are_listed_apart_from_the_standard_ones", "crates/automation/tests/it/automation.rs::custom_document_properties_are_set_listed_and_cleared_through_the_tools", "crates/ui-egui/tests/it/editing.rs::custom_properties_are_added_in_the_custom_tab_as_one_undoable_step", "crates/ui-egui/tests/it/editing.rs::custom_properties_are_changed_and_deleted_in_the_custom_tab", "crates/ui-egui/tests/it/editing.rs::the_custom_tab_refuses_names_a_custom_property_cannot_have", "crates/ui-egui/tests/it/editing.rs::a_custom_property_whose_name_the_tools_refuse_is_listed_and_left_alone", "crates/ui-egui/tests/it/editing.rs::a_custom_property_is_deleted_from_an_info_dictionary_in_the_trailer", "crates/ui-egui/tests/it/editing.rs::add_stops_at_the_256_custom_properties_the_tab_lists", "crates/organize/src/tests.rs::document_info_entries_are_cleared_from_an_info_dictionary_in_the_trailer"] +evidence = ["crates/organize/src/tests.rs::custom_document_properties_are_listed_set_removed_and_their_names_checked", "crates/render/src/inspect/tests.rs::custom_document_properties_are_listed_apart_from_the_standard_ones", "crates/automation/tests/it/automation.rs::custom_document_properties_are_set_listed_and_cleared_through_the_tools", "crates/ui-egui/tests/it/editing.rs::custom_properties_are_added_in_the_custom_tab_as_one_undoable_step", "crates/ui-egui/tests/it/editing.rs::custom_properties_are_changed_and_deleted_in_the_custom_tab", "crates/ui-egui/tests/it/editing.rs::the_custom_tab_refuses_names_a_custom_property_cannot_have", "crates/ui-egui/tests/it/editing.rs::a_custom_property_whose_name_the_tools_refuse_is_listed_and_left_alone", "crates/ui-egui/tests/it/editing.rs::a_custom_property_is_deleted_from_an_info_dictionary_in_the_trailer", "crates/ui-egui/tests/it/editing.rs::add_stops_at_the_256_custom_properties_the_tab_lists", "crates/organize/src/tests.rs::document_info_entries_are_cleared_from_an_info_dictionary_in_the_trailer"] notes = "Document Properties ▸ Custom lists them, with Name and Value fields and Add, Update (the value of the listed property the Name field names) and Delete; OK writes the changes as one undo step with the other tabs' changes. A property already in the file under a name the tools refuse (a space, a delimiter) or a name that is not UTF-8 is listed and cannot be changed or deleted there, and Add is off for a name the file already uses for an entry that is not listed (not text, only padding, or past the 256 listed), and once the tab or the file lists 256 properties. Through the tools: doc_set_info sets or clears an /Info entry under any name the spec does not use (names checked as Acrobat does: no standard name, no empty name, no spaces or delimiters, at most 127 bytes), and doc_info lists them under custom, apart from the standard entries. Not yet: mirroring them into XMP (pdfx:) as Acrobat does" [[feature]] @@ -3607,7 +3650,7 @@ milestone = "M5" status = "shipped" commands = ["comment.note"] tools = ["comment_add"] -evidence = ["crates/ui-egui/tests/it/comments.rs::sticky_note_composer_posts_and_returns_to_select", "crates/ui-egui/tests/it/comments.rs::the_comment_composer_shortcut_only_posts_the_focused_editor", "crates/annot/src/tests.rs::every_tool_creates_a_drawable_comment", "crates/annot/src/tests.rs::every_note_icon_draws_its_own_shape", "crates/annot/src/tests.rs::a_star_note_keeps_its_star_when_recoloured", "crates/engine/src/tests.rs::sticky_notes_stay_upright_where_they_are_placed_on_rotated_pages", "crates/render/src/raster.rs::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages", "crates/ui-egui/tests/it/comments.rs::a_sticky_note_on_a_turned_page_is_picked_where_it_is_drawn"] +evidence = ["crates/ui-egui/tests/it/comments.rs::sticky_note_composer_posts_and_returns_to_select", "crates/ui-egui/tests/it/comments.rs::the_comment_composer_shortcut_only_posts_the_focused_editor", "crates/annot/src/tests.rs::every_tool_creates_a_drawable_comment", "crates/annot/src/tests.rs::every_note_icon_draws_its_own_shape", "crates/annot/src/tests.rs::a_star_note_keeps_its_star_when_recoloured", "crates/engine/src/tests.rs::sticky_notes_stay_upright_where_they_are_placed_on_rotated_pages", "crates/render/src/raster/tests.rs::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages", "crates/ui-egui/tests/it/comments.rs::a_sticky_note_on_a_turned_page_is_picked_where_it_is_drawn"] notes = "our own icon drawings for the seven standard /Name values of ISO 32000-2 Table 175 (Comment, Note, Help, Insert, Key, NewParagraph, Paragraph) and seven further names (Check, Circle, Cross, CrossHairs, Star, RightArrow, UpArrow), each its own shape, chosen in Properties or comment_add, and kept when the colour changes; another name draws as Note. With a pop-up; Ctrl+Enter (also Command+Enter) posts from the focused editor, Shift+Enter adds a newline. Notes are saved with the NoRotate flag, and it is honoured: on a page with /Rotate the icon stays upright with its top-left where it was placed, and is selected and hovered there (#2583). Not yet: opening the pop-ups a file saves open (/Open); NoZoom (the icon scales with the zoom); printing and flattening still place the icon in its /Rect, turned with the page." [[feature]] @@ -3967,8 +4010,8 @@ milestone = "M5" status = "shipped" commands = ["comment.list"] tools = ["comment_reply"] -evidence = ["crates/ui-egui/tests/it/comments.rs::the_panel_posts_comments_and_replies", "crates/ui-egui/tests/it/comments.rs::ctrl_enter_posts_a_reply_and_shift_enter_adds_a_line", "crates/annot/src/tests.rs::replies_and_status_thread_onto_the_parent"] -notes = "multiline reply field: Enter or Ctrl+Enter (also Command+Enter) posts, Shift+Enter adds a newline; empty replies are not posted" +evidence = ["crates/ui-egui/tests/it/comments.rs::the_panel_posts_comments_and_replies", "crates/ui-egui/tests/it/comments.rs::ctrl_enter_posts_a_reply_and_shift_enter_adds_a_line", "crates/annot/src/tests.rs::replies_and_status_thread_onto_the_parent", "crates/ui-egui/tests/it/comments.rs::one_reply_is_deleted_from_its_menu_and_the_thread_stays"] +notes = "multiline reply field: Enter or Ctrl+Enter (also Command+Enter) posts, Shift+Enter adds a newline; empty replies are not posted. On the selected card each reply has its own \"…\" menu (Edit, Copy text, Delete); a Replace Text caret, half of its comment, has none. Missing: Set status on a reply from its menu, @mentions" [[feature]] id = "comment.set-status" @@ -4003,8 +4046,8 @@ milestone = "M5" status = "shipped" commands = ["comment.list"] tools = ["comment_delete"] -evidence = ["crates/ui-egui/tests/it/comments.rs::comments_are_selected_moved_and_deleted_with_the_select_tool", "crates/annot/src/tests.rs::delete_takes_popup_and_reply_chain"] -notes = "deletes the pop-up and the reply thread" +evidence = ["crates/ui-egui/tests/it/comments.rs::comments_are_selected_moved_and_deleted_with_the_select_tool", "crates/annot/src/tests.rs::delete_takes_popup_and_reply_chain", "crates/ui-egui/tests/it/comments.rs::one_reply_is_deleted_from_its_menu_and_the_thread_stays", "crates/ui-egui/src/tools/comments/tests.rs::a_kept_comment_is_found_where_a_delete_moved_it", "crates/automation/tests/it/automation.rs::deleting_a_reply_keeps_its_comment_and_the_other_replies", "crates/annot/src/tests.rs::a_locked_reply_keeps_what_it_answers", "crates/engine/src/tests.rs::a_stamp_with_a_locked_reply_still_has_its_drag_layers"] +notes = "deletes the pop-up and the reply thread. One reply is deleted from its own \"…\" in the Comments panel (comment_delete with the reply's id, or page and index), with its pop-up and the replies to it: the comment and its other replies stay, one undo step, and the card stays selected. A locked reply can't be deleted, nor the comment or reply it answers while it is locked (their Delete is greyed out; a stamp or signature with a locked reply still drags with its live preview)." [[feature]] id = "comment.keyboard-nudge" @@ -4030,6 +4073,18 @@ tools = ["comment_edit"] evidence = ["crates/annot/src/tests.rs::lines_and_polygons_are_reshaped_by_their_points", "crates/ui-egui/tests/it/comments.rs::line_ends_are_dragged_to_change_length_and_angle", "crates/automation/tests/it/automation.rs::comments_are_reshaped_through_the_tool"] notes = "A selected line, arrow, connected lines, polygon or cloud has a handle on each end or corner (instead of the frame); dragging one changes the length and angle (Shift holds it to 45° from the point before), with the new shape drawn while dragging, as one undo step (Edit::ReshapeAnnotation). The drawing tools keep drawing from an end (a new line starts there); the Select tool reshapes. /Rect follows the points with room for the width and arrowheads, and the appearance is drawn anew. Headless: comment_edit `points`; comment_list reports them. Measurements keep the points they measured; locked comments keep their shape. Not yet: adding or removing a corner." +[[feature]] +id = "comment.move-preview" +title = "A moved comment is drawn where it goes while it is dragged" +area = "E" +tier = "P1" +milestone = "M5" +status = "shipped" +commands = [] +tools = [] +evidence = ["crates/ui-egui/tests/it/comments.rs::a_moved_comment_is_drawn_where_it_goes_before_release", "crates/engine/src/tests.rs::comment_layer_draws_the_comment_alone"] +notes = "Dragging a rectangle, oval, line, text box or any other comment with the Select tool draws the comment itself under the pointer, not only its blue frame: it is rendered alone once as the drag starts (Document::comment_layer, a read-only copy with only that comment and no page content) and laid over the page where it goes; the page beneath keeps showing it where it was until the move is applied, as a CAD program's move does (rendering the page without it would cost a whole page at every drag on a heavy plan). Stamps and Fill & Sign marks keep their own layers, which also hide the original. Pointer frames never edit the document. Not yet: resizing shows the frame only." + [[feature]] id = "comment.edit-comment-text" title = "Edit comment text" @@ -4039,8 +4094,8 @@ milestone = "M5" status = "shipped" commands = ["comment.list"] tools = ["comment_edit"] -evidence = ["crates/annot/src/tests.rs::text_box_text_and_colour_changes_are_drawn", "crates/annot/src/tests.rs::editing_a_text_box_refits_its_rectangle", "crates/automation/tests/it/automation.rs::comments_through_tools", "crates/ui-egui/tests/it/comments.rs::ctrl_enter_posts_a_highlight_comment_and_shift_enter_adds_a_line"] -notes = "double-click a comment or card, or Edit in the menus; Ctrl+Enter (also Command+Enter) saves text in the focused comment editor, Shift+Enter adds a newline; a text box's rectangle follows the new text (wrap width and top edge stay, the height fits the wrapped lines)" +evidence = ["crates/annot/src/tests.rs::text_box_text_and_colour_changes_are_drawn", "crates/annot/src/tests.rs::editing_a_text_box_refits_its_rectangle", "crates/automation/tests/it/automation.rs::comments_through_tools", "crates/ui-egui/tests/it/comments.rs::ctrl_enter_posts_a_highlight_comment_and_shift_enter_adds_a_line", "crates/ui-egui/tests/it/comments.rs::one_reply_is_deleted_from_its_menu_and_the_thread_stays", "crates/ui-egui/tests/it/comments.rs::undo_and_redo_close_a_reply_editor_left_open"] +notes = "double-click a comment or card, or Edit in the menus (a reply from its own \"…\" menu); Ctrl+Enter (also Command+Enter) saves text in the focused comment editor, Shift+Enter adds a newline; a text box's rectangle follows the new text (wrap width and top edge stay, the height fits the wrapped lines). Undo or Redo closes the panel's editor without saving it, since either can move the comment it was open on" [[feature]] id = "comment.copy-comment-text" @@ -4051,8 +4106,8 @@ milestone = "M5" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/it/comments.rs::comments_take_checkmarks_lock_hide_and_summarize"] -notes = "card and canvas context menus" +evidence = ["crates/ui-egui/tests/it/comments.rs::comments_take_checkmarks_lock_hide_and_summarize", "crates/ui-egui/tests/it/comments.rs::one_reply_is_deleted_from_its_menu_and_the_thread_stays"] +notes = "card, reply and canvas context menus" [[feature]] id = "comment.filter-comments" @@ -4252,11 +4307,11 @@ title = "Import comments from another PDF" area = "E" tier = "P2" milestone = "M5" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["comment.import"] +tools = ["doc_import_data"] +evidence = ["crates/organize/src/comments.rs::comments_of_another_pdf_land_on_the_pages_with_the_same_numbers", "crates/organize/src/comments.rs::a_reply_on_an_earlier_page_reaches_its_comment_by_name", "crates/organize/src/comments.rs::a_comment_keeps_its_layer_and_the_layer_its_visibility", "crates/organize/src/comments.rs::a_long_thread_goes_with_its_first_comment_and_a_loop_of_replies_stays", "crates/organize/src/comments.rs::a_pdf_without_comments_copies_none", "crates/engine/src/tests.rs::importing_a_pdf_copies_its_comments", "crates/ui-egui/tests/it/pickers.rs::import_comments_takes_the_comments_of_another_pdf"] +notes = "Import comments accepts a PDF beside XFDF and FDF. Each comment goes onto the page with the same number, at the same place, with its appearance, pop-up and replies; comments on pages past the document's last page are left out. A same-named comment is replaced, as with XFDF and FDF. Not yet: redaction marks are not copied, and a PDF that needs a password is refused." [[feature]] id = "comment.summarize" @@ -4303,7 +4358,7 @@ milestone = "M12" status = "partial" commands = ["measure.distance"] tools = ["measure_distance"] -evidence = ["crates/automation/tests/it/automation.rs::measurements_calibrate_draw_save_reopen_and_export", "crates/measure/src/tests.rs::calibrated_lengths_concave_areas_and_axis_scales", "crates/ui-egui/tests/it/control.rs::measurement_tools_draw_live_calibrate_save_and_export", "crates/ui-egui/tests/it/control.rs::a_plan_scale_picked_or_typed_applies_at_once", "crates/ui-egui/src/measure_ui.rs::typed_ratios_are_read_leniently"] +evidence = ["crates/automation/tests/it/automation.rs::measurements_calibrate_draw_save_reopen_and_export", "crates/measure/src/tests.rs::calibrated_lengths_concave_areas_and_axis_scales", "crates/ui-egui/tests/it/control.rs::measurement_tools_draw_live_calibrate_save_and_export", "crates/ui-egui/tests/it/control.rs::a_plan_scale_picked_or_typed_applies_at_once", "crates/ui-egui/src/tools/measure_ui.rs::typed_ratios_are_read_leniently"] notes = "Two-point distance annotations with saved numeric captions and rectilinear scales. Partial: only single-unit decimal formats; compound (ft-in) and fractional formats are not supported (listed as unsupported, data kept), no leader lines or caption placement options, geospatial scales unsupported, and interop with Acrobat-authored measurements (reading theirs, Acrobat reading ours) is untested." [[feature]] @@ -4451,7 +4506,7 @@ milestone = "M3" status = "shipped" commands = ["form.fields"] tools = ["doc_info"] -evidence = ["crates/ui-egui/tests/it/view.rs::field_list_jumps_to_a_fields_page", "crates/render/src/inspect.rs::fields_listed_only_on_the_pages_are_listed", "crates/ui-egui/tests/it/bulk_fields.rs::fields_panel_selects_multiple_fields_and_opens_their_shared_properties", "crates/ui-egui/tests/it/bulk_fields.rs::double_clicking_a_field_in_the_panel_opens_its_individual_properties"] +evidence = ["crates/ui-egui/tests/it/view.rs::field_list_jumps_to_a_fields_page", "crates/render/src/inspect/tests.rs::fields_listed_only_on_the_pages_are_listed", "crates/ui-egui/tests/it/bulk_fields.rs::fields_panel_selects_multiple_fields_and_opens_their_shared_properties", "crates/ui-egui/tests/it/bulk_fields.rs::double_clicking_a_field_in_the_panel_opens_its_individual_properties"] notes = "list with jump-to-field; in Prepare a form, Shift/Ctrl/Command-click selects several fields and Properties edits their shared properties; double-click opens field properties; fields a file lists only as page widgets (empty /Fields) are adopted too" [[feature]] @@ -4475,7 +4530,7 @@ milestone = "M2" status = "shipped" commands = [] tools = [] -evidence = ["crates/render/src/raster.rs::widget_appearance_states", "crates/engine/src/tests.rs::validated_signatures_without_layers_keep_their_appearance", "crates/engine/src/tests.rs::real_layered_signatures_keep_their_original_appearance", "crates/engine/src/tests.rs::real_layered_signatures_round_trip_behind_a_user_password", "crates/engine/src/tests.rs::a_metadata_edit_keeps_the_original_signature_appearance", "crates/engine/src/tests.rs::saving_after_an_edit_keeps_the_baked_appearance", "crates/engine/src/tests.rs::chamfered_signature_borders_survive_trust_exports_printing_and_save", "crates/engine/src/tests.rs::mixed_signature_caption_details_survive_trust_exports_printing_and_save", "crates/engine/src/tests.rs::buttons_and_combs_left_to_the_viewer_are_displayed", "crates/forms/src/tests.rs::button_states_without_an_appearance"] +evidence = ["crates/render/src/raster/tests.rs::widget_appearance_states", "crates/engine/src/tests.rs::validated_signatures_without_layers_keep_their_appearance", "crates/engine/src/tests.rs::real_layered_signatures_keep_their_original_appearance", "crates/engine/src/tests.rs::real_layered_signatures_round_trip_behind_a_user_password", "crates/engine/src/tests.rs::a_metadata_edit_keeps_the_original_signature_appearance", "crates/engine/src/tests.rs::saving_after_an_edit_keeps_the_baked_appearance", "crates/engine/src/tests.rs::chamfered_signature_borders_survive_trust_exports_printing_and_save", "crates/engine/src/tests.rs::mixed_signature_caption_details_survive_trust_exports_printing_and_save", "crates/engine/src/tests.rs::buttons_and_combs_left_to_the_viewer_are_displayed", "crates/forms/src/tests.rs::button_states_without_an_appearance"] notes = "widgets retain their original artwork. Signature validation is a separate viewer page overlay; it is never written into document streams or included in image/text exports, vector printing or Print as Image. Saves retain signed appearance bytes. Generic in-place appearance rewriting is deferred: no supported producer template is positively identified, and caption text plus drawing uniqueness does not establish an icon role. Baked Not Verified captions remain unchanged; issue #79 is not fully resolved. Check boxes and radio buttons with no appearance for their state are shown (display-only) with their /MK border and background and, when on, the /MK /CA ZapfDingbats symbol in the /DA colour (a check or a dot by default); push buttons with none show their /MK frame and caption; files that carry their own appearances are not redrawn." [[feature]] @@ -4547,8 +4602,8 @@ 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::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/it/form_rotation.rs::a_rotated_field_draws_along_the_vertical_edge", "crates/automation/tests/it/automation.rs::rotating_a_field_through_tools", "crates/ui-egui/tests/it/prepare.rs::position_tab_rotates_a_field", "crates/ui-egui/tests/it/prepare.rs::dropdown_alignment_is_editable_and_preserved", "crates/ui-egui/tests/it/prepare.rs::list_box_alignment_is_editable_and_preserved"] -notes = "auto-size, comb, multiline, password, quadding (text, dropdown and list-box Options controls), /MK colours and /MK /R rotation (0, 90, 180, 270); approximate Helvetica metrics" +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/automation/tests/it/automation.rs::auto_sized_text_follows_a_tall_field", "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/it/form_rotation.rs::a_rotated_field_draws_along_the_vertical_edge", "crates/automation/tests/it/automation.rs::rotating_a_field_through_tools", "crates/ui-egui/tests/it/prepare.rs::position_tab_rotates_a_field", "crates/ui-egui/tests/it/prepare.rs::dropdown_alignment_is_editable_and_preserved", "crates/ui-egui/tests/it/prepare.rs::list_box_alignment_is_editable_and_preserved"] +notes = "auto-size, comb, multiline, password, quadding (text, dropdown and list-box Options controls), /MK colours and /MK /R rotation (0, 90, 180, 270); approximate Helvetica metrics. Single-line auto-size follows the field height with no 12 pt cap, as Acrobat Reader draws it; multiline and comb auto-size still stop at 12 pt, not yet checked against Acrobat" [[feature]] id = "form.rich-text-fields" @@ -4571,7 +4626,7 @@ milestone = "M6" status = "shipped" commands = ["form.field.properties"] tools = ["form_set_props", "form_fill"] -evidence = ["crates/forms/src/af.rs::numbers_format_like_acrobat", "crates/forms/src/tests.rs::formats_validation_and_calculations_run_like_acrobat", "crates/automation/tests/it/automation.rs::form_formats_and_calculations_through_tools"] +evidence = ["crates/forms/src/af/tests.rs::numbers_format_like_acrobat", "crates/forms/src/tests.rs::formats_validation_and_calculations_run_like_acrobat", "crates/automation/tests/it/automation.rs::form_formats_and_calculations_through_tools"] notes = "AFNumber_Format/Keystroke run natively: decimals, 5 separator styles, currency before/after, negative styles (red shown as minus/parentheses)" [[feature]] @@ -4583,7 +4638,7 @@ milestone = "M6" status = "shipped" commands = ["form.field.properties"] tools = ["form_set_props", "form_fill"] -evidence = ["crates/forms/src/af.rs::numbers_format_like_acrobat"] +evidence = ["crates/forms/src/af/tests.rs::numbers_format_like_acrobat"] notes = "AFPercent_Format/Keystroke" [[feature]] @@ -4595,7 +4650,7 @@ milestone = "M6" status = "shipped" commands = ["form.field.properties"] tools = ["form_set_props", "form_fill"] -evidence = ["crates/forms/src/af.rs::dates_times_and_masks", "crates/forms/src/tests.rs::formats_validation_and_calculations_run_like_acrobat", "crates/forms/src/af.rs::digits_typed_without_separators_fill_adjacent_fields"] +evidence = ["crates/forms/src/af/tests.rs::dates_times_and_masks", "crates/forms/src/tests.rs::formats_validation_and_calculations_run_like_acrobat", "crates/forms/src/af.rs::digits_typed_without_separators_fill_adjacent_fields"] notes = "AFDate_FormatEx/KeystrokeEx, AFDate_Format presets, AFTime_Format(Ex): lenient parsing, month and day names, impossible dates refused; digits typed without separators fill fields the format writes side by side by their fixed widths (20261018 for yyyymmdd, 1430 for HHMM). A run of digits is split only where the format itself has no separator: 20261018 typed into a yyyy-mm-dd field is still refused" [[feature]] @@ -4607,7 +4662,7 @@ milestone = "M6" status = "shipped" commands = ["form.field.properties"] tools = ["form_set_props", "form_fill"] -evidence = ["crates/forms/src/af.rs::dates_times_and_masks"] +evidence = ["crates/forms/src/af/tests.rs::dates_times_and_masks"] notes = "zip, zip+4, phone, SSN, arbitrary masks (9 A O X)" [[feature]] @@ -4619,7 +4674,7 @@ milestone = "M6" status = "shipped" commands = ["form.field.properties"] tools = ["form_set_props", "form_fill"] -evidence = ["crates/forms/src/af.rs::validation_and_calculation", "crates/forms/src/tests.rs::formats_validation_and_calculations_run_like_acrobat", "crates/automation/tests/it/automation.rs::form_formats_and_calculations_through_tools"] +evidence = ["crates/forms/src/af/tests.rs::validation_and_calculation", "crates/forms/src/tests.rs::formats_validation_and_calculations_run_like_acrobat", "crates/automation/tests/it/automation.rs::form_formats_and_calculations_through_tools"] notes = "AFRange_Validate with Acrobat's messages" [[feature]] @@ -4631,7 +4686,7 @@ milestone = "M6" status = "shipped" commands = ["form.field.properties"] tools = ["form_set_props", "form_fill"] -evidence = ["crates/forms/src/af.rs::validation_and_calculation", "crates/forms/src/tests.rs::formats_validation_and_calculations_run_like_acrobat"] +evidence = ["crates/forms/src/af/tests.rs::validation_and_calculation", "crates/forms/src/tests.rs::formats_validation_and_calculations_run_like_acrobat"] notes = "SUM, PRD, AVG, MIN, MAX over fields and groups" [[feature]] @@ -4643,7 +4698,7 @@ milestone = "M6" status = "shipped" commands = ["form.field.properties"] tools = ["form_set_props", "form_fill"] -evidence = ["crates/forms/src/af.rs::validation_and_calculation", "crates/forms/src/tests.rs::formats_validation_and_calculations_run_like_acrobat", "crates/automation/tests/it/automation.rs::form_formats_and_calculations_through_tools", "crates/forms/src/af.rs::notation_is_read_only_from_a_comment"] +evidence = ["crates/forms/src/af/tests.rs::validation_and_calculation", "crates/forms/src/tests.rs::formats_validation_and_calculations_run_like_acrobat", "crates/automation/tests/it/automation.rs::form_formats_and_calculations_through_tools", "crates/forms/src/af/tests.rs::notation_is_read_only_from_a_comment"] notes = "BVCALC expressions with + - * / and parentheses, escaped names, read from the script's comment as Acrobat writes it; a string or code that spells the markers is not notation" [[feature]] @@ -4667,8 +4722,8 @@ milestone = "M6" status = "shipped" commands = ["form.field.properties", "form.calculation_order"] tools = ["form_set_props", "form_fill", "form_calculation_order"] -evidence = ["crates/forms/src/tests.rs::formats_validation_and_calculations_run_like_acrobat", "crates/forms/src/tests.rs::the_calculation_order_is_checked_fixed_and_kept", "crates/forms/src/tests.rs::a_calculation_order_with_gaps_and_extra_entries", "crates/forms/src/tests.rs::two_fields_with_one_name_keep_their_places_in_the_order", "crates/engine/src/tests.rs::setting_the_calculation_order_recalculates_and_undoes", "crates/automation/tests/it/automation.rs::the_calculation_order_is_read_fixed_and_set", "crates/ui-egui/tests/it/prepare.rs::the_calculation_order_is_moved_dragged_and_fixed", "crates/engine/src/tests.rs::checkbox_and_rank_changes_recalculate_ordered_dependents", "crates/engine/src/tests.rs::dynamic_calculation_reads_and_side_effects_reach_later_fields"] -notes = "/CO order, then other calculated fields; recalculated after every fill and Clear form. Order editing: Prepare a form ▸ Calculation order lists the calculated fields in order with their formulas and values, Up/Down arrows (as Acrobat's Set Field Calculation Order dialog), Alt+Up/Down and drag, says which fields read a field calculated later, which read each other in a loop and which /CO leaves out, and Fix order moves only what must move; on the page it numbers each calculated field's step and draws arrows from the fields the focused field reads and to the fields that read it; form_calculation_order reads, sets, moves and fixes it. A new calculation joins the end of /CO and an edited one keeps its place. Acrobat's dialog is known from Adobe's help summary only, not yet observed." +evidence = ["crates/forms/src/tests.rs::formats_validation_and_calculations_run_like_acrobat", "crates/forms/src/tests.rs::the_calculation_order_is_checked_fixed_and_kept", "crates/forms/src/tests.rs::a_calculation_order_with_gaps_and_extra_entries", "crates/forms/src/tests.rs::two_fields_with_one_name_keep_their_places_in_the_order", "crates/engine/src/tests.rs::setting_the_calculation_order_recalculates_and_undoes", "crates/automation/tests/it/automation.rs::the_calculation_order_is_read_fixed_and_set", "crates/ui-egui/tests/it/prepare.rs::the_calculation_order_is_moved_dragged_and_fixed", "crates/engine/src/tests.rs::checkbox_and_rank_changes_recalculate_ordered_dependents", "crates/engine/src/tests.rs::dynamic_calculation_reads_and_side_effects_reach_later_fields", "crates/forms/src/tests.rs::missing_fields_and_built_names_are_flagged", "crates/forms/src/calc_order.rs::a_read_name_finds_a_field_or_a_group", "crates/forms/src/af/tests.rs::names_built_at_run_time_are_noticed", "crates/automation/tests/it/automation.rs::the_calculation_order_shows_formulas_and_what_they_cannot_read", "crates/ui-egui/tests/it/prepare.rs::a_formula_reading_a_missing_field_has_its_own_card"] +notes = "/CO order, then other calculated fields; recalculated after every fill and Clear form. Order editing: Prepare a form ▸ Calculation order lists the calculated fields in order with their formulas and values, Up/Down arrows (as Acrobat's Set Field Calculation Order dialog), Alt+Up/Down and drag, says which fields read a field calculated later, which read each other in a loop and which /CO leaves out, and Fix order moves only what must move; on the page it numbers each calculated field's step and draws arrows from the fields the focused field reads and to the fields that read it; form_calculation_order reads, sets, moves and fixes it. Each formula is also checked against the form: a read of a field the form doesn't have (a field deleted from under a sum, a typo in notation, a script's getField) and a script that builds the names it reads at run time are flagged, in form_calculation_order (which also gives each formula) and on the page: a red Missing field mark on the row and a card naming those formulas with a link to edit the first (no all-clear while one is broken), a grey note for built names, and both in the summary above the page. A new calculation joins the end of /CO and an edited one keeps its place. Acrobat's dialog is known from Adobe's help summary only, not yet observed." [[feature]] id = "form.event-order" @@ -4727,7 +4782,7 @@ milestone = "M6" status = "shipped" commands = ["form.clear"] tools = ["form_reset"] -evidence = ["crates/forms/src/tests.rs::reset_restores_defaults", "crates/ui-egui/tests/it/forms.rs::check_boxes_radios_and_choices"] +evidence = ["crates/forms/src/tests.rs::reset_restores_defaults", "crates/ui-egui/tests/it/forms.rs::check_boxes_radios_and_choices", "crates/forms/src/tests.rs::clearing_a_kids_value_does_not_bring_back_its_parents"] notes = "back to /DV; undoable" [[feature]] @@ -4811,8 +4866,8 @@ milestone = "M6" status = "shipped" commands = ["form.prepare"] tools = ["form_add_field", "form_set_props", "form_delete_field"] -evidence = ["crates/ui-egui/tests/it/prepare.rs::placing_moving_editing_and_deleting_a_field", "crates/ui-egui/tests/it/prepare.rs::arrow_keys_nudge_the_selected_field_instead_of_turning_the_page", "crates/ui-egui/src/prepare.rs::corner_drags_stretch_freely_and_keep_proportions_with_shift", "crates/ui-egui/tests/it/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/it/bulk_fields.rs::shared_properties_preserve_mixed_values_and_undo_the_whole_change", "crates/ui-egui/tests/it/bulk_fields.rs::select_all_in_prepare_a_form_selects_only_the_current_pages_fields", "crates/ui-egui/tests/it/bulk_fields.rs::dragging_an_empty_page_area_selects_fields_without_moving_them"] -notes = "fields shown with blue fill and name tags; select, move (drag, or the arrow keys: a point, Shift ten points, #2608), 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" +evidence = ["crates/ui-egui/tests/it/prepare.rs::placing_moving_editing_and_deleting_a_field", "crates/ui-egui/tests/it/prepare.rs::deleting_one_radio_button_keeps_the_rest_of_its_group", "crates/ui-egui/tests/it/prepare.rs::deleting_two_radio_buttons_of_a_group_at_once", "crates/forms/src/tests.rs::deleting_one_radio_button_keeps_the_group_and_its_other_buttons", "crates/forms/src/tests.rs::deleting_the_chosen_radio_button_turns_the_group_off", "crates/forms/src/tests.rs::deleting_one_copy_of_a_field_keeps_the_field_and_its_value", "crates/automation/tests/it/automation.rs::deleting_one_radio_button_through_tools_keeps_the_group", "crates/ui-egui/tests/it/prepare.rs::arrow_keys_nudge_the_selected_field_instead_of_turning_the_page", "crates/ui-egui/src/tools/prepare.rs::corner_drags_stretch_freely_and_keep_proportions_with_shift", "crates/ui-egui/tests/it/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/it/bulk_fields.rs::shared_properties_preserve_mixed_values_and_undo_the_whole_change", "crates/ui-egui/tests/it/bulk_fields.rs::select_all_in_prepare_a_form_selects_only_the_current_pages_fields", "crates/ui-egui/tests/it/bulk_fields.rs::dragging_an_empty_page_area_selects_fields_without_moving_them"] +notes = "fields shown with blue fill and name tags; select, move (drag, or the arrow keys: a point, Shift ten points, #2608), resize by corners (Shift keeps the field's proportions, #2199), Delete (every selected field, one undo step; a selected radio button or copy of a duplicated field goes on its own and the rest of its group stays, a field's last widget takes the field, #4609); 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" @@ -4847,8 +4902,8 @@ milestone = "M6" status = "shipped" commands = ["form.add.radio"] tools = ["form_add_field"] -evidence = ["crates/forms/src/tests.rs::every_field_type_can_be_added_named_and_drawn", "crates/automation/tests/it/automation.rs::preparing_a_form_through_tools"] -notes = "new group Group1… or join the selected group with the next ChoiceN" +evidence = ["crates/forms/src/tests.rs::every_field_type_can_be_added_named_and_drawn", "crates/automation/tests/it/automation.rs::preparing_a_form_through_tools", "crates/forms/src/tests.rs::a_radio_button_renamed_to_a_groups_name_joins_the_group", "crates/forms/src/tests.rs::a_joining_radio_button_with_a_taken_export_value_takes_a_free_one", "crates/forms/src/tests.rs::radio_fields_that_are_their_own_widget_join_and_are_joined", "crates/automation/tests/it/automation.rs::renaming_a_radio_button_to_a_groups_name_joins_the_group_through_tools"] +notes = "new group Group1… or join the selected group with the next ChoiceN; renaming a radio button (Properties or form_set_props) to another radio group's name moves its buttons into that group (#4949), a clashing export value becoming the next free ChoiceN; refused when either group lists its values in /Opt; a button's export value is not yet editable in Properties" [[feature]] id = "form.check-box-style" @@ -4991,8 +5046,8 @@ milestone = "M6" status = "shipped" commands = ["form.field.properties"] tools = ["form_set_props"] -evidence = ["crates/forms/src/tests.rs::button_caption_preserves_other_widgets_and_shared_dictionaries", "crates/forms/src/tests.rs::a_button_caption_is_drawn_in_its_fields_font_size_and_color", "crates/ui-egui/tests/it/prepare.rs::button_label_is_editable_without_renaming_the_field", "crates/automation/tests/it/button_caption.rs::button_caption_can_be_changed_after_creation", "crates/forms/src/tests.rs::properties_rename_and_delete", "crates/automation/tests/it/automation.rs::preparing_a_form_through_tools", "crates/forms/src/tests.rs::options_tab_flags_alignment_and_defaults", "crates/ui-egui/tests/it/prepare.rs::options_tab_sets_flags_alignment_and_defaults", "crates/automation/tests/it/form_bulk.rs::bulk_options_apply_per_field_and_refuse_an_invalid_comb_atomically"] -notes = "push buttons: per-widget normal label independent of the field name, including clearing and save/reopen, drawn in the font, size and color of the button's /DA (the form's /DR font, else Helvetica; an automatic size fits the button) and centered by Helvetica's widths; icon-only layout retained, rollover/down labels and icon layout editing remain unavailable; text: alignment, default, multi-line, scroll, rich text flag, limit, password, file selection, spell check, comb; choices: items, default, sort, custom text, multiple selection, commit immediately; check boxes and radios: checked by default, radios in unison. Shared GUI offers applicable text/choice flags, alignment, text limits and check styles; choices/defaults remain individual GUI edits" +evidence = ["crates/forms/src/tests.rs::button_caption_preserves_other_widgets_and_shared_dictionaries", "crates/forms/src/tests.rs::a_button_caption_is_drawn_in_its_fields_font_size_and_color", "crates/ui-egui/tests/it/prepare.rs::button_label_is_editable_without_renaming_the_field", "crates/automation/tests/it/button_caption.rs::button_caption_can_be_changed_after_creation", "crates/forms/src/tests.rs::properties_rename_and_delete", "crates/automation/tests/it/automation.rs::preparing_a_form_through_tools", "crates/forms/src/tests.rs::options_tab_flags_alignment_and_defaults", "crates/ui-egui/tests/it/prepare.rs::options_tab_sets_flags_alignment_and_defaults", "crates/automation/tests/it/form_bulk.rs::bulk_options_apply_per_field_and_refuse_an_invalid_comb_atomically", "crates/forms/src/tests.rs::changing_a_lists_options_repoints_its_selected_indices", "crates/forms/src/tests.rs::shrinking_a_lists_options_scrolls_a_widget_with_its_own_top_row", "crates/forms/src/tests.rs::each_widget_with_its_own_top_row_scrolls_by_itself", "crates/forms/src/tests.rs::a_list_boxs_top_row_follows_a_font_size_changed_with_its_items", "crates/forms/src/tests.rs::sorting_a_combo_boxs_items_repoints_its_selected_index", "crates/automation/tests/it/automation.rs::changing_a_lists_options_through_tools_keeps_its_saved_selection_on_the_same_items", "crates/forms/src/tests.rs::clearing_a_kids_default_does_not_bring_back_its_parents", "crates/forms/src/tests.rs::clearing_a_buttons_default_under_a_parent_with_one_leaves_it_off", "crates/automation/tests/it/automation.rs::clearing_a_kids_default_through_tools_does_not_bring_back_its_parents"] +notes = "push buttons: per-widget normal label independent of the field name, including clearing and save/reopen, drawn in the font, size and color of the button's /DA (the form's /DR font, else Helvetica; an automatic size fits the button) and centered by Helvetica's widths; icon-only layout retained, rollover/down labels and icon layout editing remain unavailable; text: alignment, default (clearing it leaves none, not a parent field's), multi-line, scroll, rich text flag, limit, password, file selection, spell check, comb; choices: items (the selected indices /I follow their items when the list is edited or sorted), default, sort, custom text, multiple selection, commit immediately; check boxes and radios: checked by default, radios in unison. Shared GUI offers applicable text/choice flags, alignment, text limits and check styles; choices/defaults remain individual GUI edits" [[feature]] id = "form.props-actions" @@ -5039,8 +5094,8 @@ milestone = "M6" status = "shipped" commands = ["form.field.properties"] tools = ["form_set_props", "form_fill"] -evidence = ["crates/ui-egui/tests/it/prepare.rs::format_validate_and_calculate_tabs", "crates/automation/tests/it/automation.rs::form_formats_and_calculations_through_tools", "crates/forms/src/tests.rs::renaming_a_field_renames_it_in_the_calculations_that_read_it", "crates/forms/src/tests.rs::a_rename_reaches_every_stored_form_of_a_calculation", "crates/automation/tests/it/automation.rs::renaming_a_field_keeps_the_formulas_that_read_it", "crates/ui-egui/tests/it/prepare.rs::renaming_a_field_says_how_many_calculations_now_use_the_new_name", "crates/forms/src/af.rs::a_regex_after_a_condition_is_not_read_as_code", "crates/forms/src/af.rs::a_line_comment_ends_at_any_line_terminator", "crates/forms/src/af.rs::a_token_ends_at_a_line_separator"] -notes = "sum/product/average/min/max of picked fields, simplified field notation; custom script pending. Renaming a field renames it in the calculations that read it (field lists, notation, and getField calls naming it in a string literal), in the rename's undo step; Field Properties says how many changed and form_set_props lists them. Not rewritten: names built at run time, other events' scripts, document-level scripts. A regular expression that starts a statement right after a `}` is still read as code, so a quote inside one can hide the getField calls after it from a rename and from the calculation order. Whether Acrobat does this is not observed." +evidence = ["crates/ui-egui/tests/it/prepare.rs::format_validate_and_calculate_tabs", "crates/automation/tests/it/automation.rs::form_formats_and_calculations_through_tools", "crates/forms/src/tests.rs::renaming_a_field_renames_it_in_the_calculations_that_read_it", "crates/forms/src/tests.rs::a_rename_reaches_every_stored_form_of_a_calculation", "crates/automation/tests/it/automation.rs::renaming_a_field_keeps_the_formulas_that_read_it", "crates/ui-egui/tests/it/prepare.rs::renaming_a_field_says_how_many_calculations_now_use_the_new_name", "crates/forms/src/af/tests.rs::a_regex_after_a_condition_is_not_read_as_code", "crates/forms/src/af/tests.rs::a_line_comment_ends_at_any_line_terminator", "crates/forms/src/af/tests.rs::a_token_ends_at_a_line_separator", "crates/automation/tests/it/automation.rs::the_calculation_order_shows_formulas_and_what_they_cannot_read"] +notes = "sum/product/average/min/max of picked fields, simplified field notation; custom script pending. form_fields shows a custom Calculate script as {script}. Renaming a field renames it in the calculations that read it (field lists, notation, and getField calls naming it in a string literal), in the rename's undo step; Field Properties says how many changed and form_set_props lists them. Not rewritten: names built at run time, other events' scripts, document-level scripts. A regular expression that starts a statement right after a `}` is still read as code, so a quote inside one can hide the getField calls after it from a rename and from the calculation order. Whether Acrobat does this is not observed." [[feature]] id = "form.props-selection-change" @@ -5212,8 +5267,8 @@ milestone = "M6" status = "partial" commands = ["tools.js_console"] tools = ["js_run"] -evidence = ["crates/js/src/tests.rs::calculate_scripts_read_fields_and_set_the_value", "crates/engine/src/tests.rs::form_javascript_validates_calculates_and_formats", "crates/automation/tests/it/automation.rs::javascript_through_tools", "crates/js/src/tests.rs::an_xfa_form_sees_the_xfa_plugin_installed", "crates/js/src/tests.rs::a_plain_form_sees_the_xfa_version_but_not_installed", "crates/engine/src/tests.rs::scripts_in_an_xfa_form_see_the_xfa_plugin_installed", "crates/js/src/tests.rs::scand_allows_separators_between_fields_but_nothing_before_or_after_them"] -notes = "boa (pure Rust) with the forms object model: event, getField/Field (value, readonly, display, colours, items, check boxes), app (with execMenuItem for the side panels and Save As, from a button only, and Print where this.print() works), util.printf/printd/printx/scand (scand reads dates as another viewer was observed to, in AcroForm and XFA scripts), console, display, color; xfa_version (3.5) in every document and xfa_installed in XFA forms, so their version check passes; no annotations, layers or dialogs (app.response returns null) yet, and no app.findComponent." +evidence = ["crates/js/src/tests.rs::calculate_scripts_read_fields_and_set_the_value", "crates/engine/src/tests.rs::form_javascript_validates_calculates_and_formats", "crates/automation/tests/it/automation.rs::javascript_through_tools", "crates/js/src/tests.rs::an_xfa_form_sees_the_xfa_plugin_installed", "crates/js/src/tests.rs::a_plain_form_sees_the_xfa_version_but_not_installed", "crates/engine/src/tests.rs::scripts_in_an_xfa_form_see_the_xfa_plugin_installed", "crates/js/src/tests.rs::scand_allows_separators_between_fields_but_nothing_before_or_after_them", "crates/js/src/tests.rs::app_alert_keeps_its_title_icon_and_buttons", "crates/js/src/tests.rs::app_alert_reads_the_object_form", "crates/js/src/tests.rs::app_alert_falls_back_on_bad_icon_and_button_numbers", "crates/js/src/tests.rs::app_alert_titles_are_clipped_and_charged", "crates/ui-egui/tests/it/javascript.rs::every_message_a_script_shows_takes_the_toast_in_turn"] +notes = "boa (pure Rust) with the forms object model: event, getField/Field (value, readonly, display, colours, items, check boxes), app (with execMenuItem for the side panels and Save As, from a button only, and Print where this.print() works), util.printf/printd/printx/scand (scand reads dates as another viewer was observed to, in AcroForm and XFA scripts), console, display, color; xfa_version (3.5) in every document and xfa_installed in XFA forms, so their version check passes; no annotations, layers or dialogs (app.response returns null) yet, and no app.findComponent. app.alert keeps its title, icon (error, warning, question, status) and buttons (OK, OK/Cancel, Yes/No, Yes/No/Cancel), given in order or as one object; js_run reports them with each message, and the app shows every message in turn in the toast with its title and icon. Still missing: answers. Every app.alert returns 1 (OK), so a script is never told No or Cancel (#4291)." [[feature]] id = "form.js-console" @@ -5296,8 +5351,8 @@ milestone = "M12" status = "partial" commands = ["file.open"] tools = ["doc_open", "doc_info", "form_fields", "form_fill", "js_run"] -evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/it/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/it/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/engine/src/tests.rs::xfa_date_arithmetic_reads_the_date_as_its_display_picture_shows_it", "crates/automation/tests/it/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/it/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/it/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/it/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/it/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::page_area_without_occur_serves_every_body_page", "apps/pdfcraft-cli/tests/it/xfa.rs::info_reports_the_laid_out_pages_and_fields", "apps/pdfcraft-cli/tests/it/xfa.rs::text_reads_the_layout_including_a_page_absent_from_the_placeholder", "apps/pdfcraft-cli/tests/it/xfa.rs::render_matches_the_engine_layout_on_the_second_page", "apps/pdfcraft-cli/tests/it/xfa.rs::a_laid_out_form_keeps_the_original_copy_permissions", "crates/engine/src/tests.rs::filling_growable_xfa_text_reflows_and_undo_restores_the_layout", "crates/engine/src/tests.rs::xfa_text_growth_refreshes_widget_geometry_but_fixed_height_fields_keep_their_widgets", "crates/automation/tests/it/automation.rs::growable_xfa_text_paginates_after_fill_save_and_print", "crates/engine/src/tests.rs::an_xfa_script_that_fails_partway_keeps_what_it_changed_before_the_error", "crates/engine/src/tests.rs::repeated_xfa_print_buttons_run_their_click_script", "crates/automation/tests/it/automation.rs::repeated_xfa_print_buttons_work_through_tools_and_after_reopen", "crates/xfa/src/tests.rs::rich_text_max_length_reaches_the_field_and_layout", "crates/automation/tests/it/automation.rs::xfa_rich_text_max_length_is_reported_and_enforced_through_tools", "crates/xfa/src/tests.rs::bmp_and_tiff_pictures_are_decoded_and_drawn", "crates/engine/src/tests.rs::active_xfa_does_not_run_pdf_container_scripts_but_keeps_requested_alerts", "crates/engine/src/tests.rs::active_xfa_without_template_scripts_does_not_initialize_pdf_fallback_scripts", "crates/engine/src/tests.rs::inactive_xfa_and_ordinary_pdfs_keep_document_helpers_and_alerts", "crates/automation/tests/it/automation.rs::active_xfa_skips_container_fallback_scripts_after_save_and_reopen", "crates/xfa/src/tests.rs::laying_a_form_out_takes_off_the_usage_rights_it_voids", "crates/engine/src/tests.rs::a_dynamic_xfa_form_is_saved_without_the_usage_rights_its_layout_voided", "crates/xfa/src/tests.rs::faces_measure_text_at_their_standard_14_widths", "crates/xfa/src/tests.rs::rich_text_links_keep_their_address", "crates/xfa/src/tests.rs::links_in_rich_text_become_link_annotations_one_per_line", "crates/xfa/src/tests.rs::a_links_pieces_share_one_address", "crates/xfa/src/tests.rs::a_rule_between_two_pieces_of_a_link_keeps_them_apart", "crates/xfa/src/tests.rs::links_to_anything_but_web_and_email_addresses_stay_plain_text", "crates/automation/tests/it/automation.rs::links_in_xfa_rich_text_are_listed_through_tools", "crates/xfa/src/tests.rs::rounded_corners_make_a_border_one_rounded_shape", "crates/xfa/src/tests.rs::a_rounded_box_cut_by_a_page_break_keeps_its_corners_on_each_page", "crates/xfa/src/tests.rs::rounded_boxes_are_written_as_curved_paths", "crates/xfa/src/tests.rs::a_flowed_subform_is_drawn_with_its_border_under_its_content", "crates/xfa/src/tests.rs::a_check_box_sits_where_its_margins_caption_and_alignment_put_it", "crates/xfa/src/tests.rs::a_check_box_caption_without_a_reserve_takes_the_rest_of_the_field", "crates/xfa/src/tests.rs::a_radio_button_sits_where_a_check_box_would", "crates/xfa/src/tests.rs::a_check_box_caption_without_a_reserve_is_measured_as_it_is_drawn", "crates/xfa/src/tests.rs::signature_fields_are_named_by_their_som_path", "crates/xfa/src/tests.rs::a_signature_on_a_laid_out_box_survives_a_new_layout"] -notes = "Active XFA layout skips PDF-container document-script initialization for the Acrobat console/field context, so fallback-viewer scripts do not run there. Scripts remain in the file/listing; explicit console/field and XFA alerts still run. Ordinary PDF, static XFA and unlaid-out shells keep document scripts; static-conversion undo restores the XFA policy. Changed growable multiline text (no fixed h) reflows from final datasets after edits, with or without scripts; pages/widgets refresh and undo/redo, save/reopen and print retain all text. Oversized text still uses the existing read-only page-text fallback with a warning; fixed-height fields do not reflow. Repeated page-master buttons resolve their own PDF field to the shared XFA click script; the numPages-based print script requests printing from every copy, including after save/reopen (#2572). Rich-text exData/maxLength is carried into AcroForm MaxLen, reported by form_fields and enforced by form_fill (Unicode characters); limits and accepted values survive save/reopen (#2570). laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters (a page area with no occur repeats without limit, one with occur max=1 is used once, one with occur min=1 the body never reached gets a page after the body, as Acrobat Pro does on a synthetic form), breaks, repeated header rows, borders and fills of flowed subforms (a page break between their children gives each page's part a closed border, the margins repeated at the cut, as measured on a synthetic form in another viewer, and a child's own page break breaks them the same way; one holding an item that doesn't fit on its page, cut across pages or placed past the content area's bottom, is drawn without its border, with a layout warning), captions, rounded and inverted border corners, rich text (its http, https and mailto links clickable), JPEG/PNG/GIF/BMP/TIFF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes and radio groups (each box placed by its field's margins, caption and paragraph alignment as measured on two synthetic forms in another viewer: with no caption at the right of the content area, beside a reserved caption where the alignment puts it, and a caption without a reserve taking the rest of the field; a check box's own margin larger than the box draws a bigger box there, which isn't copied), drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields (each named by its SOM path, under a generated field for each step above it, so that another viewer, which lays the form out again, finds a signature made here, as seen on a synthetic form, and a box signed here keeps its signature, and that name, when a script lays the form out again; a box whose path, or a part of it, the file already gives another field keeps its own name, and its signature isn't found there yet), image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; usage rights the form carried (/Perms /UR3) are taken off with a note, as the layout voids them and a form with void usage rights opens with every field locked; doc_info reports xfa_layout. CLI info reports the laid-out page/field counts and dimensions while retaining the input file size and raw counts as pdf_pages/acroform_fields; text and render use those pages too, preserving copy permissions and leaving the source file unchanged (#1645). XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, formattedValue in a date display picture's pattern (number and text display pictures still give the raw value), presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. A script that fails partway keeps what it changed before the error, as JavaScript does (forms often trip over an object an earlier version had after their first lines did the work). Missing: a page area's relevant=\"-print\" (on a synthetic form Acrobat still prints the page but leaves it blank; PdfCraft prints its content, #2574); a page break before the first section (Acrobat opens an empty body page first when the first section has breakBefore targetType=pageArea, with or without startNew, as seen on synthetic forms; PdfCraft skips a break on an empty page and lays out one page fewer, #2574); loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; text is measured at the exact standard-14 widths of the face it is drawn in (bold, italic and Times included), not in the template's own typefaces, and Acrobat fidelity is eyeballed on real forms, not measured (#60)" +evidence = ["crates/xfa/src/tests.rs::a_draw_turns_counter_clockwise_about_its_anchor", "crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/it/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/it/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/engine/src/tests.rs::xfa_date_arithmetic_reads_the_date_as_its_display_picture_shows_it", "crates/automation/tests/it/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/it/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/it/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/it/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/it/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::page_area_without_occur_serves_every_body_page", "apps/pdfcraft-cli/tests/it/xfa.rs::info_reports_the_laid_out_pages_and_fields", "apps/pdfcraft-cli/tests/it/xfa.rs::text_reads_the_layout_including_a_page_absent_from_the_placeholder", "apps/pdfcraft-cli/tests/it/xfa.rs::render_matches_the_engine_layout_on_the_second_page", "apps/pdfcraft-cli/tests/it/xfa.rs::a_laid_out_form_keeps_the_original_copy_permissions", "crates/engine/src/tests.rs::filling_growable_xfa_text_reflows_and_undo_restores_the_layout", "crates/engine/src/tests.rs::xfa_text_growth_refreshes_widget_geometry_but_fixed_height_fields_keep_their_widgets", "crates/automation/tests/it/automation.rs::growable_xfa_text_paginates_after_fill_save_and_print", "crates/engine/src/tests.rs::an_xfa_script_that_fails_partway_keeps_what_it_changed_before_the_error", "crates/engine/src/tests.rs::repeated_xfa_print_buttons_run_their_click_script", "crates/automation/tests/it/automation.rs::repeated_xfa_print_buttons_work_through_tools_and_after_reopen", "crates/xfa/src/tests.rs::rich_text_max_length_reaches_the_field_and_layout", "crates/automation/tests/it/automation.rs::xfa_rich_text_max_length_is_reported_and_enforced_through_tools", "crates/xfa/src/tests.rs::bmp_and_tiff_pictures_are_decoded_and_drawn", "crates/engine/src/tests.rs::active_xfa_does_not_run_pdf_container_scripts_but_keeps_requested_alerts", "crates/engine/src/tests.rs::active_xfa_without_template_scripts_does_not_initialize_pdf_fallback_scripts", "crates/engine/src/tests.rs::inactive_xfa_and_ordinary_pdfs_keep_document_helpers_and_alerts", "crates/automation/tests/it/automation.rs::active_xfa_skips_container_fallback_scripts_after_save_and_reopen", "crates/xfa/src/tests.rs::laying_a_form_out_takes_off_the_usage_rights_it_voids", "crates/engine/src/tests.rs::a_dynamic_xfa_form_is_saved_without_the_usage_rights_its_layout_voided", "crates/xfa/src/tests.rs::faces_measure_text_at_their_standard_14_widths", "crates/xfa/src/tests.rs::rich_text_links_keep_their_address", "crates/xfa/src/tests.rs::links_in_rich_text_become_link_annotations_one_per_line", "crates/xfa/src/tests.rs::a_links_pieces_share_one_address", "crates/xfa/src/tests.rs::a_rule_between_two_pieces_of_a_link_keeps_them_apart", "crates/xfa/src/tests.rs::links_to_anything_but_web_and_email_addresses_stay_plain_text", "crates/automation/tests/it/automation.rs::links_in_xfa_rich_text_are_listed_through_tools", "crates/xfa/src/tests.rs::rounded_corners_make_a_border_one_rounded_shape", "crates/xfa/src/tests.rs::a_rounded_box_cut_by_a_page_break_keeps_its_corners_on_each_page", "crates/xfa/src/tests.rs::rounded_boxes_are_written_as_curved_paths", "crates/xfa/src/tests.rs::a_flowed_subform_is_drawn_with_its_border_under_its_content", "crates/xfa/src/tests.rs::a_check_box_sits_where_its_margins_caption_and_alignment_put_it", "crates/xfa/src/tests.rs::a_check_box_caption_without_a_reserve_takes_the_rest_of_the_field", "crates/xfa/src/tests.rs::a_radio_button_sits_where_a_check_box_would", "crates/xfa/src/tests.rs::a_check_box_caption_without_a_reserve_is_measured_as_it_is_drawn", "crates/js/src/tests.rs::message_box_keeps_its_title_icon_and_buttons", "crates/js/src/tests.rs::app_alert_in_an_xfa_script_reads_app_alerts_arguments", "crates/engine/src/tests.rs::xfa_message_boxes_keep_their_title_icon_and_buttons", "crates/xfa/src/tests.rs::signature_fields_are_named_by_their_som_path", "crates/xfa/src/tests.rs::a_signature_on_a_laid_out_box_survives_a_new_layout"] +notes = "A draw placed by position turns about its anchor (rotate); fields, subforms and draws in a flow don't. Active XFA layout skips PDF-container document-script initialization for the Acrobat console/field context, so fallback-viewer scripts do not run there. Scripts remain in the file/listing; explicit console/field and XFA alerts still run. Ordinary PDF, static XFA and unlaid-out shells keep document scripts; static-conversion undo restores the XFA policy. Changed growable multiline text (no fixed h) reflows from final datasets after edits, with or without scripts; pages/widgets refresh and undo/redo, save/reopen and print retain all text. Oversized text still uses the existing read-only page-text fallback with a warning; fixed-height fields do not reflow. Repeated page-master buttons resolve their own PDF field to the shared XFA click script; the numPages-based print script requests printing from every copy, including after save/reopen (#2572). Rich-text exData/maxLength is carried into AcroForm MaxLen, reported by form_fields and enforced by form_fill (Unicode characters); limits and accepted values survive save/reopen (#2570). laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters (a page area with no occur repeats without limit, one with occur max=1 is used once, one with occur min=1 the body never reached gets a page after the body, as Acrobat Pro does on a synthetic form), breaks, repeated header rows, borders and fills of flowed subforms (a page break between their children gives each page's part a closed border, the margins repeated at the cut, as measured on a synthetic form in another viewer, and a child's own page break breaks them the same way; one holding an item that doesn't fit on its page, cut across pages or placed past the content area's bottom, is drawn without its border, with a layout warning), captions, rounded and inverted border corners, rich text (its http, https and mailto links clickable), JPEG/PNG/GIF/BMP/TIFF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes and radio groups (each box placed by its field's margins, caption and paragraph alignment as measured on two synthetic forms in another viewer: with no caption at the right of the content area, beside a reserved caption where the alignment puts it, and a caption without a reserve taking the rest of the field; a check box's own margin larger than the box draws a bigger box there, which isn't copied), drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields (each named by its SOM path, under a generated field for each step above it, so that another viewer, which lays the form out again, finds a signature made here, as seen on a synthetic form, and a box signed here keeps its signature, and that name, when a script lays the form out again; a box whose path, or a part of it, the file already gives another field keeps its own name, and its signature isn't found there yet), image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; usage rights the form carried (/Perms /UR3) are taken off with a note, as the layout voids them and a form with void usage rights opens with every field locked; doc_info reports xfa_layout. CLI info reports the laid-out page/field counts and dimensions while retaining the input file size and raw counts as pdf_pages/acroform_fields; text and render use those pages too, preserving copy permissions and leaving the source file unchanged (#1645). XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, formattedValue in a date display picture's pattern (number and text display pictures still give the raw value), presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. A script that fails partway keeps what it changed before the error, as JavaScript does (forms often trip over an object an earlier version had after their first lines did the work). Missing: a page area's relevant=\"-print\" (on a synthetic form Acrobat still prints the page but leaves it blank; PdfCraft prints its content, #2574); a page break before the first section (Acrobat opens an empty body page first when the first section has breakBefore targetType=pageArea, with or without startNew, as seen on synthetic forms; PdfCraft skips a break on an empty page and lays out one page fewer, #2574); loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; text is measured at the exact standard-14 widths of the face it is drawn in (bold, italic and Times included), not in the template's own typefaces, and Acrobat fidelity is eyeballed on real forms, not measured (#60). xfa.host.messageBox keeps its title, icon and buttons, and app.alert in an XFA script reads its own argument order; every message box still answers OK (1) (#4291)." [[feature]] id = "form.xfa-formcalc" @@ -5308,8 +5363,8 @@ milestone = "M12" status = "partial" commands = ["file.open"] tools = ["doc_open", "form_fill", "js_run"] -evidence = ["crates/js/src/tests.rs::arithmetic_strings_and_comparisons_follow_formcalc_rules", "crates/js/src/tests.rs::built_in_functions_cover_the_common_set", "crates/js/src/tests.rs::control_flow_variables_and_functions_work", "crates/js/src/tests.rs::form_objects_resolve_assign_and_record_effects", "crates/js/src/tests.rs::hostile_formcalc_scripts_end_with_an_error_and_bounded_output", "crates/js/src/tests.rs::formcalc_values_results_and_errors_stay_bounded", "crates/engine/src/tests.rs::dynamic_xfa_forms_run_formcalc_scripts", "crates/engine/src/tests.rs::hostile_formcalc_scripts_cannot_hang_or_bloat_the_document", "crates/engine/src/tests.rs::a_formcalc_calculate_result_is_cut_before_it_is_saved"] -notes = "a native FormCalc interpreter (crates/js/src/formcalc.rs) over the same form table, effects and budgets as the XFA JavaScript model: expressions and FormCalc coercions, var, if/while/for/foreach/func, accessors ($, $host, $form, $record, $layout, $event, xfa., .., [n], [*], _name instance managers) and the arithmetic, logical, string, date/time, financial and unit built-ins. Bounded: steps and loop iterations, statement and expression nesting, operator/accessor chain length, strings (1 MiB; messages and thrown errors clipped to 4 KiB, field values and calculate results to 64 KiB, noted; copying and comparing long strings is charged to the step budget, so it ends on wasm, which has no clock), lists (each object once), clamped dates, the instance cap, and a deadline the script itself honours after the engine stops waiting. Missing: locale-aware pictures beyond simple number and date ones, Format/Parse time and text pictures, Get/Post/Put (refused), $template/$connectionSet, user-defined functions across scripts, a cap on a script's total memory (each string and list is capped, but many variables can each hold one), real Uuid() values (deterministic, from the step count) and the wall clock on wasm (Date() reads 1970-01-01 and Time() midnight there), and checking against Acrobat on real Designer forms beyond the fixtures" +evidence = ["crates/js/src/tests.rs::arithmetic_strings_and_comparisons_follow_formcalc_rules", "crates/js/src/tests.rs::built_in_functions_cover_the_common_set", "crates/js/src/tests.rs::control_flow_variables_and_functions_work", "crates/js/src/tests.rs::form_objects_resolve_assign_and_record_effects", "crates/js/src/tests.rs::hostile_formcalc_scripts_end_with_an_error_and_bounded_output", "crates/js/src/tests.rs::formcalc_values_results_and_errors_stay_bounded", "crates/engine/src/tests.rs::dynamic_xfa_forms_run_formcalc_scripts", "crates/engine/src/tests.rs::hostile_formcalc_scripts_cannot_hang_or_bloat_the_document", "crates/engine/src/tests.rs::a_formcalc_calculate_result_is_cut_before_it_is_saved", "crates/js/src/tests.rs::formcalc_message_box_keeps_its_title_icon_and_buttons"] +notes = "a native FormCalc interpreter (crates/js/src/formcalc.rs) over the same form table, effects and budgets as the XFA JavaScript model: expressions and FormCalc coercions, var, if/while/for/foreach/func, accessors ($, $host, $form, $record, $layout, $event, xfa., .., [n], [*], _name instance managers) and the arithmetic, logical, string, date/time, financial and unit built-ins. Bounded: steps and loop iterations, statement and expression nesting, operator/accessor chain length, strings (1 MiB; messages and thrown errors clipped to 4 KiB, field values and calculate results to 64 KiB, noted; copying and comparing long strings is charged to the step budget, so it ends on wasm, which has no clock), lists (each object once), clamped dates, the instance cap, and a deadline the script itself honours after the engine stops waiting. Missing: locale-aware pictures beyond simple number and date ones, Format/Parse time and text pictures, Get/Post/Put (refused), $template/$connectionSet, user-defined functions across scripts, a cap on a script's total memory (each string and list is capped, but many variables can each hold one), real Uuid() values (deterministic, from the step count) and the wall clock on wasm (Date() reads 1970-01-01 and Time() midnight there), and checking against Acrobat on real Designer forms beyond the fixtures. $host.messageBox keeps its title, icon and buttons; it answers OK (1) for now (#4291)." [[feature]] id = "form.xfa-data" @@ -5344,8 +5399,8 @@ milestone = "M5" status = "shipped" commands = ["sign.fill.text"] tools = ["fill_sign_add"] -evidence = ["crates/ui-egui/tests/it/fill_sign.rs::text_marks_and_date", "crates/annot/src/tests.rs::fill_and_sign_items_are_drawn", "crates/automation/tests/it/automation.rs::fill_and_sign_through_tools", "crates/engine/src/tests.rs::typed_text_reads_across_as_displayed_on_rotated_pages"] -notes = "typewriter FreeText, 10 pt Helvetica. Reads across as displayed on pages with /Rotate (sideways-stored scans): the box follows the displayed axes and the appearance is turned back (PdfCraft key /PCTextRotate); other apps that regenerate the appearance from /DA draw it unturned. A later page rotation does not re-turn it. Field detection does not work on scanned (image-only) forms yet" +evidence = ["crates/annot/src/tests.rs::indirect_typewriter_names_keep_outer_whitespace_in_summaries", "crates/ui-egui/tests/it/comments.rs::typewriter_edit_composer_keeps_trailing_blank_lines", "crates/ui-egui/tests/it/comments.rs::other_edit_composers_keep_trimming_trailing_whitespace", "crates/ui-egui/tests/it/comments.rs::moved_fill_and_sign_text_is_drawn_where_it_goes_before_release", "crates/engine/src/tests.rs::fill_sign_multiline_text_survives_engine_save_reopen_and_render", "crates/ui-egui/tests/it/fill_sign.rs::fill_sign_enter_adds_lines_outside_click_commits_and_escape_cancels", "crates/automation/tests/it/automation.rs::multiline_fill_sign_engine_round_trip_preserves_lines_and_appearance", "crates/ui-egui/tests/it/fill_sign.rs::text_marks_and_date", "crates/annot/src/tests.rs::fill_and_sign_items_are_drawn", "crates/automation/tests/it/automation.rs::fill_and_sign_through_tools", "crates/engine/src/tests.rs::typed_text_reads_across_as_displayed_on_rotated_pages", "crates/ui-egui/tests/it/fill_sign.rs::fill_text_colour_survives_preview_commit_settings_and_save", "crates/automation/tests/it/automation.rs::fill_sign_text_and_date_colours_are_saved_and_validated"] +notes = "Indirect IT is resolved when recognizing typewriter contents and Fill & Sign marks; comment listing and properties retain direct Subtype as before; composer edits preserve the typewriter text as written, while other comments retain their existing trimming. Enter inserts a newline; the editor grows with its lines, outside clicks commit and Escape cancels. Nonempty text preserves leading/internal/trailing line breaks, including after save/reopen; fill_sign_add sizes all wrapped rows for the saved appearance (#5850). typewriter FreeText, 10 pt Helvetica. Reads across as displayed on pages with /Rotate (sideways-stored scans): the box follows the displayed axes and the appearance is turned back (PdfCraft key /PCTextRotate); other apps that regenerate the appearance from /DA draw it unturned. A later page rotation does not re-turn it. Field detection does not work on scanned (image-only) forms yet; typed fractional font sizes commit once; Fill & Sign text/date colour is remembered and preserved in preview and saved appearances (#2444)" [[feature]] id = "form.fill-sign-marks" @@ -5368,8 +5423,8 @@ milestone = "M5" status = "shipped" commands = ["sign.fill.date"] tools = ["fill_sign_add", "fill_sign_date_format"] -evidence = ["crates/ui-egui/tests/it/fill_sign.rs::text_marks_and_date", "crates/automation/tests/it/automation.rs::fill_and_sign_through_tools", "crates/ui-egui/tests/it/date_format.rs::fill_and_sign_dates_follow_the_date_format_preference_and_it_is_remembered", "crates/engine/src/dates.rs::presets_write_the_day_in_their_own_order", "crates/engine/src/dates.rs::names_follow_the_date_language", "crates/ui-egui/tests/it/date_format.rs::month_names_follow_the_date_language_or_else_the_interface_language", "crates/ui-egui/tests/it/date_format.rs::a_date_the_pdf_cannot_hold_is_refused_with_a_warning_first"] -notes = "local date in Preferences ▸ Date format (default m/d/yyyy; 14 presets, year-first slash forms included, or any pattern in Acrobat date letters with \\ escapes, checked and previewed as typed, remembered; only whole date codes accepted; month and weekday names in the interface languages, Czech months in the genitive after a day, following the interface language unless one is picked; fill_sign_add takes a one-off format and language). Beyond Acrobat: its Fill & Sign has no format setting (only Acrobat Sign accounts do). Missing: Fill & Sign text is Helvetica/WinAnsi, so a date with letters outside Western European (Czech ř, Japanese, Chinese names) is refused with a warning instead of being saved as ?; the OS locale's format isn't offered as a preset; names in languages without an interface translation" +evidence = ["crates/ui-egui/tests/it/fill_sign.rs::text_marks_and_date", "crates/automation/tests/it/automation.rs::fill_and_sign_through_tools", "crates/ui-egui/tests/it/date_format.rs::fill_and_sign_dates_follow_the_date_format_preference_and_it_is_remembered", "crates/engine/src/dates.rs::presets_write_the_day_in_their_own_order", "crates/engine/src/dates.rs::names_follow_the_date_language", "crates/ui-egui/tests/it/date_format.rs::month_names_follow_the_date_language_or_else_the_interface_language", "crates/ui-egui/tests/it/date_format.rs::a_date_the_pdf_cannot_hold_is_refused_with_a_warning_first", "crates/automation/tests/it/automation.rs::fill_sign_text_and_date_colours_are_saved_and_validated"] +notes = "local date in Preferences ▸ Date format (default m/d/yyyy; 14 presets, year-first slash forms included, or any pattern in Acrobat date letters with \\ escapes, checked and previewed as typed, remembered; only whole date codes accepted; month and weekday names in the interface languages, Czech months in the genitive after a day, following the interface language unless one is picked; fill_sign_add takes a one-off format and language). Beyond Acrobat: its Fill & Sign has no format setting (only Acrobat Sign accounts do). Missing: Fill & Sign text is Helvetica/WinAnsi, so a date with letters outside Western European (Czech ř, Japanese, Chinese names) is refused with a warning instead of being saved as ?; the OS locale's format isn't offered as a preset; names in languages without an interface translation; typed fractional font sizes commit once; Fill & Sign text/date colour is remembered and preserved in preview and saved appearances (#2444)" [[feature]] id = "form.fill-sign-signature" @@ -5380,7 +5435,7 @@ 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/it/fill_sign.rs::drawn_signatures_resize_from_their_corners", "crates/ui-egui/tests/it/fill_sign.rs::drawn_and_typed_signatures_stay_upright_on_rotated_pages", "crates/automation/tests/it/automation.rs::typed_signatures_stay_upright_on_rotated_pages", "crates/ui-egui/tests/it/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/it/fill_sign.rs::image_signature_live_resize_and_move_preserve_background_and_hide_moving_handles", "crates/ui-egui/tests/it/fill_sign.rs::embedded_image_signature_live_gestures_follow_document_and_view_rotation", "crates/automation/tests/it/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/it/fill_sign.rs::image_signature_preview_follows_pointer_at_page_size_with_zoom_and_rotation", "crates/ui-egui/tests/it/fill_sign.rs::image_signature_placement_selects_resize_handles_and_requires_reselecting_to_repeat", "crates/ui-egui/tests/it/fill_sign.rs::typed_signatures_and_initials", "crates/ui-egui/tests/it/fill_sign.rs::signing_draws_a_signature_once_and_places_it", "crates/ui-egui/tests/it/fill_sign.rs::changing_saved_signatures_and_initials_preserves_placed_marks", "crates/ui-egui/tests/it/fill_sign.rs::changing_drawn_signatures_can_be_cancelled_or_switched_to_type", "crates/ui-egui/tests/it/fill_sign.rs::saved_signature_cards_remove_and_add_without_changing_the_document", "crates/ui-egui/tests/it/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/it/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/it/automation.rs::fill_and_sign_through_tools", "crates/ui-egui/tests/it/fill_sign.rs::image_signatures_and_initials_can_be_imported_placed_and_remembered", "crates/ui-egui/tests/it/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/it/automation.rs::image_signatures_through_tools_preserve_transparency_and_survive_save", "crates/ui-egui/tests/it/fill_sign.rs::fill_and_sign_tools_pick_up_placed_marks_to_move_and_resize_them", "crates/ui-egui/tests/it/fill_sign.rs::select_tool_picks_up_a_signature_over_a_form_field", "crates/ui-egui/tests/it/fill_sign.rs::scanned_signatures_are_framed_cleaned_adjusted_placed_and_remembered", "crates/ui-egui/tests/it/fill_sign.rs::scan_cancel_errors_and_stale_answers_preserve_saved_signatures", "crates/automation/tests/it/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/it/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", "crates/ui-egui/src/comments.rs::shift_keeps_the_current_proportions_unless_the_box_has_its_own"] +evidence = ["crates/ui-egui/tests/it/fill_sign.rs::drawn_signatures_resize_from_their_corners", "crates/ui-egui/tests/it/fill_sign.rs::drawn_and_typed_signatures_stay_upright_on_rotated_pages", "crates/automation/tests/it/automation.rs::typed_signatures_stay_upright_on_rotated_pages", "crates/ui-egui/tests/it/fill_sign.rs::image_signature_corners_restore_original_aspect_after_edge_resize_and_reopen", "crates/ui-egui/src/tools/comments/tests.rs::proportional_corners_keep_the_opposite_anchor_and_edges_stretch_one_axis", "crates/ui-egui/tests/it/fill_sign.rs::image_signature_live_resize_and_move_preserve_background_and_hide_moving_handles", "crates/ui-egui/tests/it/fill_sign.rs::embedded_image_signature_live_gestures_follow_document_and_view_rotation", "crates/automation/tests/it/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/it/fill_sign.rs::image_signature_preview_follows_pointer_at_page_size_with_zoom_and_rotation", "crates/ui-egui/tests/it/fill_sign.rs::image_signature_placement_selects_resize_handles_and_requires_reselecting_to_repeat", "crates/ui-egui/tests/it/fill_sign.rs::typed_signatures_and_initials", "crates/ui-egui/tests/it/fill_sign.rs::signing_draws_a_signature_once_and_places_it", "crates/ui-egui/tests/it/fill_sign.rs::changing_saved_signatures_and_initials_preserves_placed_marks", "crates/ui-egui/tests/it/fill_sign.rs::changing_drawn_signatures_can_be_cancelled_or_switched_to_type", "crates/ui-egui/tests/it/fill_sign.rs::saved_signature_cards_remove_and_add_without_changing_the_document", "crates/ui-egui/tests/it/fill_sign.rs::long_typed_names_fit_the_placed_signature_and_keep_every_outline", "crates/ui-egui/src/tools/fill_sign.rs::long_signature_previews_have_clear_margins", "crates/ui-egui/tests/it/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/it/automation.rs::fill_and_sign_through_tools", "crates/ui-egui/tests/it/fill_sign.rs::image_signatures_and_initials_can_be_imported_placed_and_remembered", "crates/ui-egui/tests/it/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/it/automation.rs::image_signatures_through_tools_preserve_transparency_and_survive_save", "crates/ui-egui/tests/it/fill_sign.rs::fill_and_sign_tools_pick_up_placed_marks_to_move_and_resize_them", "crates/ui-egui/tests/it/fill_sign.rs::select_tool_picks_up_a_signature_over_a_form_field", "crates/ui-egui/tests/it/fill_sign.rs::scanned_signatures_are_framed_cleaned_adjusted_placed_and_remembered", "crates/ui-egui/tests/it/fill_sign.rs::scan_cancel_errors_and_stale_answers_preserve_saved_signatures", "crates/automation/tests/it/automation.rs::scanned_signatures_through_tools_drop_the_paper_and_report_bad_arguments", "crates/engine/src/signature_scan/tests.rs::a_photo_is_scanned_into_a_trimmed_transparent_signature_in_its_ink_colour", "crates/ui-egui/tests/it/fill_sign.rs::undo_button_and_ctrl_z_take_back_one_drawn_stroke_at_a_time", "crates/ui-egui/src/tools/fill_sign.rs::undo_removes_the_last_stroke_and_skips_empty_ones", "crates/ui-egui/src/tools/comments/tests.rs::shift_keeps_the_current_proportions_unless_the_box_has_its_own"] 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; 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]] @@ -5780,8 +5835,8 @@ milestone = "M8" status = "shipped" commands = ["redact.apply"] tools = ["redact_apply"] -evidence = ["crates/redact/src/tests.rs::images_are_removed_or_have_their_pixels_cleared", "crates/redact/src/tests.rs::redaction_clears_pixels_intersected_without_covering_their_centres"] -notes = "intersected pixel footprints are cleared conservatively in a copy; DCT/JPX/JBIG2/CCITT images removed whole" +evidence = ["crates/redact/src/tests.rs::images_are_removed_or_have_their_pixels_cleared", "crates/redact/src/tests.rs::redaction_clears_pixels_intersected_without_covering_their_centres", "crates/redact/src/tests.rs::a_fax_image_has_its_covered_pixels_cleared_instead_of_being_removed", "crates/redact/src/tests.rs::group_3_on_bytes_is_removed_whole_and_other_fax_images_cleared", "crates/redact/src/tests.rs::an_image_whose_predictor_is_given_by_reference_has_the_covered_pixel_cleared", "crates/redact/src/tests.rs::a_jbig2_image_has_its_covered_pixels_cleared_instead_of_being_removed", "crates/redact/src/tests.rs::a_jbig2_image_that_cannot_be_cleared_for_certain_is_removed_whole", "crates/redact/src/tests.rs::a_redacted_jbig2_scan_keeps_neither_its_code_nor_its_globals", "crates/redact/src/tests.rs::a_cleared_black_and_white_scan_is_stored_as_group_4", "crates/redact/src/tests.rs::globals_a_redacted_jbig2_image_shares_with_another_page_leave_the_file"] +notes = "intersected pixel footprints are cleared conservatively in a copy, CCITT fax and JBIG2 images (scans stored as Group 4 or JBIG2, with their /JBIG2Globals) included; DCT/JPX images, Group 3 on bytes (which the bootstrap renderer may draw as other rows than the decoder reads), and a JBIG2 image whose page isn't its size, removed whole. The copy is Flate, or for a one-bit image of one component Group 4, or JBIG2 when it was JBIG2, whichever is smallest as stored, and refers to no globals; every other image using a redacted image's globals is stored again without them (Flate, Group 4 or JBIG2, whichever is smallest), so they leave the file (one that can't be decoded refuses the redaction)" [[feature]] id = "protect.redact-vectors" @@ -6300,7 +6355,7 @@ milestone = "M9" status = "shipped" commands = ["sign.panel"] tools = [] -evidence = ["crates/ui-egui/tests/it/signing.rs::drawing_a_signature_creating_an_id_signing_and_trusting", "crates/ui-egui/tests/it/signing.rs::certifying_without_a_visible_signature", "crates/ui-egui/src/canvas.rs::signature_badges_keep_signed_visible_widgets_on_the_page"] +evidence = ["crates/ui-egui/tests/it/signing.rs::drawing_a_signature_creating_an_id_signing_and_trusting", "crates/ui-egui/tests/it/signing.rs::certifying_without_a_visible_signature", "crates/ui-egui/src/canvas/tests.rs::signature_badges_keep_signed_visible_widgets_on_the_page"] notes = "signed/valid, problems, invalid and certified bars; visible signed widgets also carry a separate viewer validation overlay. Trust and explicit validation invalidate view caches without editing document artwork; baked appearance captions remain unchanged." [[feature]] @@ -6544,8 +6599,8 @@ milestone = "M10" status = "shipped" commands = ["ocr.recognize"] tools = ["ocr_recognize", "ocr_status"] -evidence = ["crates/engine/src/tests.rs::recognize_text_makes_a_scanned_page_searchable", "crates/automation/tests/it/automation.rs::ocr_tools_make_a_scan_searchable", "crates/ui-egui/tests/it/ocr.rs::recognize_text_dialog_adds_searchable_text"] -notes = "ocrs engine (MIT/Apache) with CC-BY-SA models fetched by `cargo xtask models`; the desktop release packages ship them (#103; packaging/env.sh stage_models), the web build does not yet; runs on a worker thread with progress; one undoable step; pages with text are skipped unless asked (ocr.skip-renderable-text)." +evidence = ["crates/ocr/src/lib.rs::model_discovery_prefers_the_complete_onnx_pair", "crates/ocr/src/lib.rs::model_discovery_falls_back_to_legacy_when_the_onnx_pair_is_incomplete", "crates/ocr/src/lib.rs::model_discovery_refuses_mixed_format_pairs", "crates/ocr/src/lib.rs::model_discovery_keeps_legacy_only_installations", "crates/engine/src/tests.rs::recognize_text_makes_a_scanned_page_searchable", "crates/automation/tests/it/automation.rs::ocr_tools_make_a_scan_searchable", "crates/ui-egui/tests/it/ocr.rs::recognize_text_dialog_adds_searchable_text"] +notes = "ocrs engine (MIT/Apache) with CC-BY-SA ONNX models fetched and SHA-256 checked by `cargo xtask models`; within each search directory, a complete ONNX pair takes precedence over a complete legacy RTEN pair, incomplete and mixed-format pairs are not combined, and directory precedence is unchanged; the desktop release packages ship them (#103; packaging/env.sh stage_models), the web build does not yet; runs on a worker thread with progress; one undoable step; pages with text are skipped unless asked (ocr.skip-renderable-text)." [[feature]] id = "ocr.recognize-range" @@ -6872,8 +6927,8 @@ milestone = "M10" status = "shipped" commands = ["create.images"] tools = ["doc_create"] -evidence = ["crates/create/src/tests.rs::jpeg_2000_images_are_embedded_as_is", "crates/create/src/tests.rs::images_export_as_jpeg_unchanged_and_others_as_png", "crates/create/src/tests.rs::embedded_icc_profiles_tag_the_image_colour_space"] -notes = "PNG, JPEG, TIFF (multi-page), GIF (first frame), BMP, and JPEG 2000 (JP2 or codestream, embedded as JPXDecode with its capture resolution). JPEG and PNG ICC profiles are kept as /ICCBased; TIFF profiles are not read yet" +evidence = ["crates/create/src/tests.rs::black_and_white_images_are_stored_at_one_bit", "crates/create/src/tests.rs::jpeg_2000_images_are_embedded_as_is", "crates/create/src/tests.rs::images_export_as_jpeg_unchanged_and_others_as_png", "crates/create/src/tests.rs::embedded_icc_profiles_tag_the_image_colour_space"] +notes = "PNG, JPEG, TIFF (multi-page), GIF (first frame), BMP, and JPEG 2000 (JP2 or codestream, embedded as JPXDecode with its capture resolution). JPEG and PNG ICC profiles are kept as /ICCBased; TIFF profiles are not read yet. A picture of only pure black and white (a 1-bit PNG or TIFF, eight-bit grey, a two-colour palette, RGB without a profile) is stored at one bit per pixel, as CCITT Group 4 or Flate, whichever is smaller" [[feature]] id = "create.from-multipage-tiff" @@ -6944,8 +6999,8 @@ milestone = "M10" status = "shipped" commands = ["create.blank"] tools = ["doc_create"] -evidence = ["crates/ui-egui/tests/it/create.rs::opening_images_and_text_converts_them_to_new_pdfs", "crates/create/src/tests.rs::blank_documents", "crates/automation/tests/it/automation.rs::creating_and_reducing_through_tools"] -notes = "" +evidence = ["crates/ui-egui/tests/it/create.rs::opening_images_and_text_converts_them_to_new_pdfs", "crates/ui-egui/tests/it/create.rs::a_blank_page_is_on_the_system_regions_paper", "crates/ui-egui/src/persist/paper.rs::region_decides_the_paper", "crates/create/src/tests.rs::blank_documents", "crates/automation/tests/it/automation.rs::creating_and_reducing_through_tools"] +notes = "The page is the system region's paper (#5644): US Letter in the US, Canada, Mexico, the Philippines and the other CLDR Letter regions, A4 elsewhere; Windows' own Region paper size (iPaperSize) first, then the system locale (macOS) or LC_ALL/LC_PAPER/LANG; Letter when the region is unknown (web). The doc_create tool keeps Letter unless given width and height. Missing: a page size or count chooser like Acrobat's New Document dialog." [[feature]] id = "create.from-html" @@ -7052,7 +7107,7 @@ milestone = "M10" status = "partial" commands = ["export.docx"] tools = ["doc_export_office"] -evidence = ["crates/export/src/lib.rs::word_table_cell_widths_follow_the_columns_they_span", "crates/export/src/lib.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/edit/src/tests.rs::reading_keeps_dotted_leader_entries_whole_and_in_order", "crates/engine/src/tests.rs::exporting_office_keeps_dotted_leader_entries_as_paragraphs", "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/it/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::a_link_inside_a_sentence_is_not_a_table_column", "crates/export/src/lib.rs::sentences_that_run_on_into_links_are_not_a_table", "crates/export/src/lib.rs::a_superscript_beside_a_number_keeps_its_table", "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/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", "crates/export/src/lib.rs::docx_keeps_a_shared_page_background", "crates/edit/src/tests.rs::a_page_painted_first_with_a_full_page_fill_has_that_background", "crates/engine/src/tests.rs::exporting_to_word_keeps_the_page_background", "crates/export/src/lib.rs::an_italic_word_stays_in_its_sentence", "crates/export/src/lib.rs::a_sentence_keeps_its_reading_order", "crates/export/src/lib.rs::runs_keep_the_spaces_the_source_had_between_them", "crates/export/src/lib.rs::a_paragraph_is_a_heading_only_when_its_text_is", "crates/automation/tests/it/automation.rs::exporting_to_word_keeps_an_italic_word_inside_its_sentence"] +evidence = ["crates/export/src/tests.rs::word_table_cell_widths_follow_the_columns_they_span", "crates/export/src/tests.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/edit/src/tests.rs::reading_keeps_dotted_leader_entries_whole_and_in_order", "crates/engine/src/tests.rs::exporting_office_keeps_dotted_leader_entries_as_paragraphs", "crates/export/src/tests.rs::word_does_not_invent_borders_for_aligned_invoice_fields", "crates/export/src/tests.rs::word_overlap_does_not_consume_invoice_body_space", "crates/export/src/tests.rs::word_keeps_a_page_with_a_clear_image_and_big_title_in_flow", "crates/export/src/tests.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/it/automation.rs::exporting_an_invoice_to_word_keeps_the_watermark_out_of_flow", "crates/export/src/tests.rs::docx_is_a_word_package", "crates/export/src/tests.rs::text_colour_survives_every_format", "crates/engine/src/tests.rs::exporting_office_files_keeps_text_colour", "crates/export/src/tests.rs::grid_text_becomes_a_real_table_in_every_format", "crates/export/src/tests.rs::a_link_inside_a_sentence_is_not_a_table_column", "crates/export/src/tests.rs::sentences_that_run_on_into_links_are_not_a_table", "crates/export/src/tests.rs::a_superscript_beside_a_number_keeps_its_table", "crates/export/src/tests.rs::word_opens_text_with_control_characters_and_long_pages", "crates/engine/src/tests.rs::exporting_office_files_keeps_images", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", "crates/export/src/tests.rs::docx_keeps_a_shared_page_background", "crates/edit/src/tests.rs::a_page_painted_first_with_a_full_page_fill_has_that_background", "crates/engine/src/tests.rs::exporting_to_word_keeps_the_page_background", "crates/export/src/tests.rs::an_italic_word_stays_in_its_sentence", "crates/export/src/tests.rs::a_sentence_keeps_its_reading_order", "crates/export/src/tests.rs::runs_keep_the_spaces_the_source_had_between_them", "crates/export/src/tests.rs::a_paragraph_is_a_heading_only_when_its_text_is", "crates/automation/tests/it/automation.rs::exporting_to_word_keeps_an_italic_word_inside_its_sentence"] notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. 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 (cell widths sum the grid columns they occupy, including cells after spans, verified in the XML; a row whose blocks run on into each other, such as a link or superscript inside a sentence, or overlap, as a paragraph's lines do, is text and not a table row, #2806), per-page sections/sizes, the page colour (a full-page opaque fill painted first, such as Edit a PDF ▸ Background) as the document background when every page shares one non-white colour (Word has no per-page background, so differing colours are dropped; #2992; checked in the XML only, not yet opened in Word), 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; dotted-leader contents entries keep the title, dots and page number together in separate paragraphs (#1757); 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. A block that continues another on its line (an italic case name, a link, a superscript) is a run in its own size, bold/italic and colour inside that paragraph, keeping the source's word space between them, rather than a paragraph of its own; a lead-in in bold no longer makes its whole line a heading (#4158). Not yet: a style change that wraps onto the next line still splits its paragraph there (the reading blocks keep style boundaries, #4158), exact font matching, vector rules, arbitrary paint-order/transparency, general exact layout fidelity." [[feature]] @@ -7064,9 +7119,21 @@ milestone = "M10" status = "shipped" commands = ["export.rtf"] tools = ["doc_export_office"] -evidence = ["crates/export/src/lib.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/export/src/lib.rs::rtf_escapes_and_sizes", "crates/export/src/lib.rs::text_colour_survives_every_format", "crates/export/src/lib.rs::word_opens_text_with_control_characters_and_long_pages", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf"] +evidence = ["crates/export/src/tests.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/export/src/tests.rs::rtf_escapes_and_sizes", "crates/export/src/tests.rs::text_colour_survives_every_format", "crates/export/src/tests.rs::word_opens_text_with_control_characters_and_long_pages", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf"] notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Paragraphs with sizes, bold, italic, text colour (colortbl), Unicode; a block that continues another on its line is a formatted group inside that paragraph (#4158), though a style change that wraps onto the next line still splits it; page breaks. Images are left out." + +[[feature]] +id = "create.export-markdown" +title = "Export to Markdown" +area = "J" +tier = "P3" +milestone = "M10" +status = "partial" +commands = [] +tools = ["doc_export_office"] +evidence = ["crates/export/src/tests.rs::markdown_has_headings_paragraphs_and_page_rules_in_order", "crates/export/src/tests.rs::markdown_escapes_markup_and_keeps_emphasis_and_tables", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf"] +notes = "Requested in #1595; not an Acrobat export. doc_export_office with a .md or .markdown path writes CommonMark with GitHub pipe tables: headings (levels 1 to 6), paragraphs with bold and italic (as HTML tags where Markdown's markers would not be read as emphasis), tables with their first row as the header, and a rule between pages; text Markdown would read as markup is escaped. Missing: a menu item, images, lists and links (the export's layout model has none of the last two), and text colour." [[feature]] id = "create.export-xlsx" title = "Export to Excel (.xlsx) with table detection" @@ -7088,18 +7155,18 @@ milestone = "M10" status = "partial" commands = ["export.pptx"] tools = ["doc_export_office"] -evidence = ["crates/export/src/lib.rs::table_spans_do_not_consume_text_they_do_not_emit", - "crates/export/src/pptx.rs::pptx_is_a_presentationml_package", - "crates/export/src/pptx.rs::pptx_slide_has_title_body_and_images_in_order", - "crates/export/src/pptx.rs::pptx_text_colour_and_formatting", - "crates/export/src/pptx.rs::pptx_empty_pages_produce_valid_slide", - "crates/export/src/pptx.rs::pptx_slide_dimensions_and_clamping", - "crates/export/src/pptx.rs::pptx_table_cell_with_largest_font_stays_in_table_and_not_in_title", - "crates/export/src/pptx.rs::pptx_opens_in_libreoffice", - "crates/export/src/pptx.rs::pptx_package_parts_are_complete_and_consistent", - "crates/export/src/pptx.rs::hostile_sizes_do_not_overflow_the_slide_position", - "crates/export/src/lib.rs::grid_text_becomes_a_real_table_in_every_format", - "crates/export/src/lib.rs::text_colour_survives_every_format", +evidence = ["crates/export/src/tests.rs::table_spans_do_not_consume_text_they_do_not_emit", + "crates/export/src/pptx/tests.rs::pptx_is_a_presentationml_package", + "crates/export/src/pptx/tests.rs::pptx_slide_has_title_body_and_images_in_order", + "crates/export/src/pptx/tests.rs::pptx_text_colour_and_formatting", + "crates/export/src/pptx/tests.rs::pptx_empty_pages_produce_valid_slide", + "crates/export/src/pptx/tests.rs::pptx_slide_dimensions_and_clamping", + "crates/export/src/pptx/tests.rs::pptx_table_cell_with_largest_font_stays_in_table_and_not_in_title", + "crates/export/src/pptx/tests.rs::pptx_opens_in_libreoffice", + "crates/export/src/pptx/tests.rs::pptx_package_parts_are_complete_and_consistent", + "crates/export/src/pptx/tests.rs::hostile_sizes_do_not_overflow_the_slide_position", + "crates/export/src/tests.rs::grid_text_becomes_a_real_table_in_every_format", + "crates/export/src/tests.rs::text_colour_survives_every_format", "crates/engine/src/tests.rs::exporting_office_files_keeps_images", "crates/engine/src/tests.rs::exporting_office_files_keeps_text_colour", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", @@ -7152,8 +7219,8 @@ milestone = "M10" status = "shipped" commands = ["export.all_images"] tools = ["doc_export_all_images"] -evidence = ["crates/create/src/tests.rs::images_export_as_jpeg_unchanged_and_others_as_png", "crates/create/src/tests.rs::indexed_and_one_bit_images_decode", "crates/create/src/tests.rs::jpeg_images_with_a_soft_mask_export_as_png_with_alpha", "crates/create/src/tests.rs::soft_masks_of_another_size_and_stencil_and_colour_key_masks_become_alpha", "crates/create/src/tests.rs::soft_mask_decode_ranges_become_exported_alpha", "crates/create/src/tests.rs::soft_mask_decode_maps_packed_and_jpeg_masks_before_resizing", "crates/automation/tests/it/automation.rs::exporting_all_images_through_tools", "crates/ui-egui/tests/it/export.rs::export_dialogs_write_images_and_text", "crates/create/src/tests.rs::lossless_decode_maps_each_component_before_colour_conversion", "crates/create/src/tests.rs::lossless_indexed_decode_rounds_in_index_units_then_clamps"] -notes = "each image once (page resources and nested forms); JPEG unchanged unless it has transparency, others PNG (gray/RGB/CMYK/ICC/Indexed, 1–16 bpc, stencil masks); transparency as PNG alpha: /SMask (scaled to the image size, /Matte un-blended), stencil /Mask and colour-key /Mask, JPEG images included (#645); JPX/JBIG2/CCITT masks are left out (image exported opaque); JPX/JBIG2/CCITT/separations reported as skipped; minimum size filter. Lossless images apply per-component Decode; Indexed values round and clip before palette lookup. Missing, null or malformed Decode arrays keep the default mapping. Soft-mask Decode intervals preserve opacity before resizing, including indirect arrays and endpoints. Still limited: 16-bit samples use high bytes; general JPEG image Decode, ICC transforms, and calibrated colors remain unsupported." +evidence = ["crates/create/src/tests.rs::ccitt_fax_images_export_as_their_picture", "crates/create/src/tests.rs::group_3_ccitt_masks_and_parameters_by_reference_export_as_their_picture", "crates/create/src/tests.rs::images_export_as_jpeg_unchanged_and_others_as_png", "crates/create/src/tests.rs::indexed_and_one_bit_images_decode", "crates/create/src/tests.rs::jpeg_images_with_a_soft_mask_export_as_png_with_alpha", "crates/create/src/tests.rs::soft_masks_of_another_size_and_stencil_and_colour_key_masks_become_alpha", "crates/create/src/tests.rs::soft_mask_decode_ranges_become_exported_alpha", "crates/create/src/tests.rs::soft_mask_decode_maps_packed_and_jpeg_masks_before_resizing", "crates/automation/tests/it/automation.rs::exporting_all_images_through_tools", "crates/ui-egui/tests/it/export.rs::export_dialogs_write_images_and_text", "crates/create/src/tests.rs::lossless_decode_maps_each_component_before_colour_conversion", "crates/create/src/tests.rs::lossless_indexed_decode_rounds_in_index_units_then_clamps", "crates/create/src/tests.rs::jbig2_images_export_as_their_picture", "crates/create/src/tests.rs::jbig2_globals_are_decoded_within_a_limit_of_their_own", "crates/automation/tests/it/automation.rs::doc_optimize_stores_black_and_white_images_as_jbig2", "crates/create/src/tests.rs::symbol_coded_jbig2_images_export_as_their_picture", "crates/create/src/tests.rs::jbig2_images_behind_another_filter_export_as_their_picture"] +notes = "each image once (page resources and nested forms), up to what one export may decode and hold of files (2 GiB or twice the document), images past that left out with the reason; JPEG unchanged unless it has transparency, others PNG (gray/RGB/CMYK/ICC/Indexed, 1–16 bpc, stencil masks); transparency as PNG alpha: /SMask (scaled to the image size, /Matte un-blended), stencil /Mask and colour-key /Mask, JPEG images included (#645); JPX masks are left out (image exported opaque); JPX images and separations reported as skipped; minimum size filter. Lossless images apply per-component Decode; Indexed values round and clip before palette lookup. Missing, null or malformed Decode arrays keep the default mapping. Soft-mask Decode intervals preserve opacity before resizing, including indirect arrays and endpoints. Still limited: 16-bit samples use high bytes; general JPEG image Decode, ICC transforms, and calibrated colors remain unsupported. CCITT fax images (Group 3 and 4, with their /DecodeParms, behind other filters too) export as their picture, masks included; byte-aligned Group 3 is read by the end-of-line codes when the data starts with one and on bytes otherwise, and one cut short is reported. Still limited: byte-aligned Group 3 with codes but none before the first row (not as T.4 sends them) is reported, or at /K 0 may export wrong. JBIG2 images (with the /JBIG2Globals of their own /DecodeParms entry, within 32 MiB, behind other filters too) export as their picture, masks included; the JBIG2 page has to be the image's size (the renderer draws the whole page into the image)." [[feature]] id = "create.export-html" @@ -7164,7 +7231,7 @@ milestone = "M10" status = "partial" commands = ["export.html"] tools = ["doc_export_office"] -evidence = ["crates/export/src/lib.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/export/src/lib.rs::html_has_headings_paragraphs_and_images_in_order", "crates/export/src/lib.rs::text_colour_survives_every_format", "crates/export/src/lib.rs::grid_text_becomes_a_real_table_in_every_format", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", "crates/automation/tests/it/automation.rs::exporting_office_files_says_which_pages_left_form_text_out", "crates/edit/src/tests.rs::reading_follows_forms_as_far_as_the_renderer_and_says_where_it_stops"] +evidence = ["crates/export/src/tests.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/export/src/tests.rs::html_has_headings_paragraphs_and_images_in_order", "crates/export/src/tests.rs::text_colour_survives_every_format", "crates/export/src/tests.rs::grid_text_becomes_a_real_table_in_every_format", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", "crates/automation/tests/it/automation.rs::exporting_office_files_says_which_pages_left_form_text_out", "crates/edit/src/tests.rs::reading_follows_forms_as_far_as_the_renderer_and_says_where_it_stops"] notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Single HTML file with headings, paragraphs (with text colour), inline images, and detected tables with spans/empty cells. Text and images in form XObjects are read 50 levels deep, as the renderer does, and up to 50 000 forms a page; the tool lists pages past that in truncated_pages, with a warning (#3120). Not yet: multi-page folder output, CSS from fonts." [[feature]] @@ -7272,7 +7339,7 @@ milestone = "M2" status = "shipped" commands = [] tools = ["text_extract"] -evidence = ["crates/render/src/text.rs::rtl_runs_come_out_in_logical_order_and_cjk_has_no_spaces", "crates/render/src/text.rs::lines_words_and_search", "crates/automation/tests/it/automation.rs::parallel_text_extraction_keeps_page_order_and_follows_edits"] +evidence = ["crates/render/src/text/tests.rs::rtl_runs_come_out_in_logical_order_and_cjk_has_no_spaces", "crates/render/src/text/tests.rs::lines_words_and_search", "crates/automation/tests/it/automation.rs::parallel_text_extraction_keeps_page_order_and_follows_edits"] notes = "" [[feature]] @@ -7312,8 +7379,8 @@ milestone = "M11" status = "shipped" commands = ["optimize.reduce"] tools = ["doc_reduce"] -evidence = ["crates/ui-egui/tests/it/create.rs::reduce_file_size_writes_a_compact_copy", "crates/ui-egui/tests/it/create.rs::reduce_file_size_saves_nothing_when_the_file_is_already_small", "crates/engine/src/tests.rs::create_and_reduce", "crates/automation/tests/it/automation.rs::creating_and_reducing_through_tools", "crates/automation/tests/it/automation.rs::optimizing_through_tools", "crates/optimize/tests/optimize.rs::an_identity_decode_array_does_not_stop_reduce"] -notes = "images above 225 ppi to 150 ppi JPEG medium (after merging page pictures printed as one-pixel strips into one image), thumbnails dropped, resources merged, object streams; scanner images with the default decode array are included; a copy that isn't smaller is not saved (\"already as small as it can be made\"); runs in the background with a progress bar and Cancel" +evidence = ["crates/ui-egui/tests/it/create.rs::reduce_file_size_writes_a_compact_copy", "crates/optimize/tests/optimize.rs::a_picture_with_a_stencil_mask_is_downsampled_under_its_mask", "crates/optimize/tests/optimize.rs::a_picture_whose_mask_is_not_a_stencil_is_left_alone", "crates/ui-egui/tests/it/create.rs::reduce_file_size_saves_nothing_when_the_file_is_already_small", "crates/engine/src/tests.rs::create_and_reduce", "crates/automation/tests/it/automation.rs::creating_and_reducing_through_tools", "crates/automation/tests/it/automation.rs::optimizing_through_tools", "crates/optimize/tests/optimize.rs::an_identity_decode_array_does_not_stop_reduce"] +notes = "images above 225 ppi to 150 ppi JPEG medium (after merging page pictures printed as one-pixel strips into one image), black-and-white images averaged down to 300 ppi above 450 ppi, and stored as CCITT Group 4 whenever that is smaller, thumbnails dropped, resources merged, object streams; scanner images with the default decode array are included; pictures under a stencil mask are included and the mask stays as it is, though samples it hides still count when resampling, so their colour can reach its edge; pictures with a colour-key mask are left alone; a copy that isn't smaller is not saved (\"already as small as it can be made\"); runs in the background with a progress bar and Cancel" [[feature]] id = "optimize.compatibility-level" @@ -7336,7 +7403,7 @@ milestone = "M11" status = "partial" commands = ["optimize.advanced"] tools = ["doc_optimize"] -evidence = ["crates/automation/tests/it/optimize_content_boundaries.rs::fragment_merge_preserves_a_comment_across_content_pieces", "crates/ui-egui/tests/it/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy", "crates/automation/tests/it/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/it/automation.rs::a_deleted_image_is_not_kept_by_reduce_or_optimize", "crates/ui-egui/tests/it/create.rs::the_pdf_optimizer_dialog_picks_a_preset", "crates/automation/tests/it/automation.rs::a_page_printed_in_strips_gets_small_through_tools", "crates/optimize/tests/optimize.rs::the_mobile_preset_makes_a_printed_page_small"] +evidence = ["crates/automation/tests/it/optimize_content_boundaries.rs::fragment_merge_preserves_a_comment_across_content_pieces", "crates/ui-egui/tests/it/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy", "crates/automation/tests/it/automation.rs::optimizing_through_tools", "crates/ui-egui/src/workflows/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/it/automation.rs::a_deleted_image_is_not_kept_by_reduce_or_optimize", "crates/ui-egui/tests/it/create.rs::the_pdf_optimizer_dialog_picks_a_preset", "crates/automation/tests/it/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" [[feature]] @@ -7348,8 +7415,8 @@ milestone = "M11" status = "partial" 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/it/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", "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::image_resolution_uses_the_pages_physical_unit", "crates/optimize/tests/optimize.rs::optimization_retains_an_image_below_threshold_on_a_scaled_page", "crates/optimize/tests/optimize.rs::shared_forms_are_measured_in_each_pages_physical_units"] -notes = "colour and grayscale, bicubic, effective resolution measured where drawn (every use, with page-local UserUnit scaling, through nested forms up to 12 deep; when a use can't be measured, nothing is resampled); 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::settings_choose_what_happens", "crates/automation/tests/it/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", "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::image_resolution_uses_the_pages_physical_unit", "crates/optimize/tests/optimize.rs::optimization_retains_an_image_below_threshold_on_a_scaled_page", "crates/optimize/tests/optimize.rs::shared_forms_are_measured_in_each_pages_physical_units", "crates/optimize/tests/optimize.rs::ambiguous_monochrome_text_is_recompressed_without_lossy_reduction", "crates/optimize/tests/optimize.rs::indexed_thin_rules_survive_averaging_for_both_palette_orders_and_decodes", "crates/optimize/src/images.rs::an_indexed_images_palette_says_which_sample_is_dark", "crates/optimize/tests/optimize.rs::jbig2_scans_are_averaged_and_stored_again", "crates/optimize/src/images.rs::it_matches_the_slow_way", "crates/automation/tests/it/automation.rs::doc_optimize_averages_black_and_white_scans_down", "crates/ui-egui/tests/it/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy"] +notes = "colour and grayscale, bicubic, effective resolution measured where drawn (every use, with page-local UserUnit scaling, through nested forms up to 12 deep; when a use can't be measured, nothing is resampled); image fragments stacked edge to edge are merged first (side-by-side tiles not yet); monochrome grey, image masks and indexed images (1-bit, CCITT fax, JBIG2, and eight-bit black-and-white grey and RGB packed to one bit) averaged by area to 300 ppi above 450 ppi by default, a tie to the dark sample (black in grey, painted in a mask, the darker palette entry over DeviceGray or DeviceRGB, after a decode array of two different numbers), then stored as Group 4 or 1-bit Flate (Monochrome Images ▸ Downsample to; doc_optimize mono); not separations or DeviceN (their tint transforms aren't read), calibrated or ICC grey, indexed images over another base or with a table over 4 KiB, images drawn or listed where /DefaultGray or /DefaultRGB is or may be in force (an indexed image also used beyond the page and form resources read, as an annotation's appearance), images with a colour key, which can hide the dark sample, or a soft mask, halftones and dithers (most of their new pixels would cover both black and white, and their tones turn solid), images with a square of four such new pixels (a small screened picture on a white page; ragged scanned text has them along its strokes too, and keeps its resolution), or images wider than a fax row (1,048,576 samples), and Acrobat's bicubic and subsampling choices for monochrome aren't offered" [[feature]] id = "optimize.recompress-images" @@ -7369,11 +7436,11 @@ title = "Monochrome compression (JBIG2, CCITT G4)" area = "K" tier = "P1" milestone = "M11" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "partial" +commands = ["optimize.advanced", "optimize.reduce"] +tools = ["doc_optimize", "doc_reduce"] +evidence = ["crates/optimize/src/images.rs::flate_images_past_the_runs_allowance_stay_as_they_are", "crates/optimize/tests/optimize.rs::eight_bit_black_and_white_images_become_one_bit", "crates/filters/src/ccitt.rs::bitmaps_round_trip", "crates/filters/src/ccitt.rs::a_page_of_text_like_marks_round_trips_and_shrinks", "crates/filters/src/ccitt.rs::what_it_cannot_encode_is_an_error", "crates/optimize/tests/optimize.rs::black_and_white_images_become_ccitt_group_4", "crates/optimize/tests/optimize.rs::group_3_fax_images_become_group_4", "crates/optimize/tests/optimize.rs::rgb_and_two_colour_indexed_black_and_white_images_become_one_bit", "crates/optimize/tests/optimize.rs::black_and_white_rgb_used_beyond_the_page_content_read_stays_rgb", "crates/optimize/tests/optimize.rs::black_and_white_rgb_drawn_only_where_the_optimizer_reads_becomes_grey", "crates/optimize/tests/optimize.rs::black_and_white_rgb_in_resources_its_page_tree_and_a_form_share_becomes_grey", "crates/automation/tests/it/automation.rs::doc_optimize_stores_black_and_white_images_as_ccitt_group_4", "crates/ui-egui/tests/it/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy", "crates/filters/src/mq.rs::the_annex_h_test_sequence_encodes_to_its_bytes", "crates/filters/src/jbig2.rs::bitmaps_round_trip", "crates/filters/src/jbig2.rs::a_page_of_text_like_marks_round_trips_and_shrinks", "crates/optimize/tests/optimize.rs::black_and_white_images_become_jbig2", "crates/automation/tests/it/automation.rs::doc_optimize_stores_black_and_white_images_as_jbig2", "crates/filters/src/jbig2/symbols.rs::repeated_marks_are_stored_once", "crates/filters/src/jbig2/symbols.rs::scattered_marks_round_trip", "crates/optimize/tests/optimize.rs::rendered_text_becomes_jbig2_symbols", "crates/optimize/tests/optimize.rs::jbig2_scans_are_averaged_and_stored_again", "crates/optimize/src/images.rs::jbig2_images_past_the_runs_allowance_stay_as_they_are"] +notes = "All of a run's images share one allowance of decoding, filters, decoders and pixels alike (about 1,000 scanned pages' worth, more for a longer file); those past it stay as they are, those the image pass read being counted in the report, which doc_optimize and doc_reduce return and the panel doesn't show yet. CCITT Group 4, lossless: PDF Optimizer ▸ Images ▸ Monochrome Images (on by default, so Reduce File Size does it too; doc_optimize mono) stores a 1-bit image of one colour component (grey, indexed, a separation) or an image mask, kept with no filter or general-purpose ones, as Group 4 when that is smaller as stored; the samples decode to the same bits, so colour space, decode array and masks stay. Packed to one bit first, then stored the same way (colour-key masks aside): eight-bit grey of only black and white, eight-bit DeviceRGB of only black and white (as DeviceGray; calibrated RGB, RGB with a profile, RGB drawn or listed where /DefaultGray or /DefaultRGB is in force or referred to from anywhere but the page and form resources read (an annotation's appearance or a pattern drawing it, directly or through resources or a form shared with a page; another image's /Alternates), and RGB whose soft mask has a /Matte aside), and an indexed image of 2, 4 or 8 bits that uses only palette entries 0 and 1 (keeping its palette), unless the grey or colour settings make it smaller still, as they can a dithered scan downsampled to JPEG, its soft mask counted. Group 4 wins on scans; on clean computer-drawn 1-bit art Flate can be smaller, and the image then stays. Group 3 fax images are stored again as Group 4 when that is smaller (damaged Group 3 with end-of-line codes as the decoder recovers it, as it reports no damage there; byte-aligned Group 3 with end-of-line codes but none before the first row, not as T.4 sends them, as PdfCraft draws it, which at /K 0 may be other rows than were coded). CCITT fax images are decoded to be downsampled (optimize.downsample-images), and a resampled image is stored as Group 4 or 1-bit Flate, whichever is smaller. Lossless JBIG2 with bounded exact-copy symbol coding is available too, with image export; lossy JBIG2 remains unsupported. JBIG2 images are read too: averaged like any (under Retain stored as JBIG2 again), and, not resampled, stored again as the compression asks when that is smaller (as JBIG2 symbols where its own coding is a generic region, say); a symbol-coded scan, averaged, loses its exact repeats and is often larger, and then stays as it is; one whose JBIG2 page isn't its size is left alone. Not yet: inline images, soft masks and /Mask images, Group 4 for rows wider than 32,767 samples." [[feature]] id = "optimize.only-if-smaller" @@ -8012,7 +8079,7 @@ milestone = "M10" status = "shipped" commands = ["print.dialog"] tools = ["doc_print", "printers"] -evidence = ["crates/ui-egui/tests/it/print.rs::paper_choice_survives_cancel_and_settings_restore", "crates/ui-egui/tests/it/print.rs::restored_paper_keeps_preview_and_saved_sheet_dimensions", "crates/ui-egui/tests/it/print.rs::print_dialog_lays_out_sheets_and_saves_a_pdf", "crates/engine/src/tests.rs::printing_lays_out_sheets_that_render", "crates/ui-egui/src/web_print.rs::a_job_draws_every_sheet_at_its_planned_resolution_then_builds_the_page", "crates/ui-egui/src/web_print.rs::the_print_page_turns_sideways_sheets_names_other_sizes_and_ends_without_a_blank_page", "crates/print/src/tests.rs::every_paper_reaches_the_spooler_by_a_name_of_its_size", "crates/automation/src/printing.rs::printing_to_a_printer_feeds_the_laid_out_paper"] +evidence = ["crates/ui-egui/tests/it/print.rs::paper_choice_survives_cancel_and_settings_restore", "crates/ui-egui/tests/it/print.rs::restored_paper_keeps_preview_and_saved_sheet_dimensions", "crates/ui-egui/tests/it/print.rs::print_dialog_lays_out_sheets_and_saves_a_pdf", "crates/engine/src/tests.rs::printing_lays_out_sheets_that_render", "crates/ui-egui/src/workflows/web_print.rs::a_job_draws_every_sheet_at_its_planned_resolution_then_builds_the_page", "crates/ui-egui/src/workflows/web_print.rs::the_print_page_turns_sideways_sheets_names_other_sizes_and_ends_without_a_blank_page", "crates/print/src/tests.rs::every_paper_reaches_the_spooler_by_a_name_of_its_size", "crates/automation/src/printing.rs::printing_to_a_printer_feeds_the_laid_out_paper"] notes = "⌘P; settings and live sheet preview; on the web the same dialog prints through the browser's print dialog; last paper selection, including cancelled Print dialogs, persists by canonical name; this change does not alter persistence of other print preferences. CUPS jobs from the dialog and doc_print ask for the chosen paper (media) in every size: PPD keywords for the common ones, PWG 5101.1 names for A6, A0–A2, ARCH and ANSI C–E, checked against a synthetic PPD with cupsfilter, not on a physical large-format printer (#3752). On macOS jobs still take the PPD's default paper tray until craft-print sends Auto Select (#3752)" [[feature]] @@ -8024,8 +8091,8 @@ milestone = "M10" status = "shipped" commands = ["print.dialog"] tools = ["printers", "printer_options", "doc_print"] -evidence = ["crates/print/src/tests.rs::spooler_arguments_and_printer_list", "crates/print/src/tests.rs::driverless_printers_join_the_list_without_a_queue", "crates/print/src/tests.rs::windows_printers_carry_one_default_and_keep_their_names", "crates/print/src/tests.rs::printer_options_come_from_the_ppd", "crates/print/src/tests.rs::printer_option_defaults_and_job_arguments", "crates/ui-egui/tests/it/print.rs::print_dialog_shows_the_printer_driver_options", "crates/ui-egui/tests/it/print.rs::printers_listed_in_the_background_preselect_the_default_unless_one_was_picked", "crates/ui-egui/src/web_print.rs::resolution_falls_with_the_sheet_count_and_long_jobs_are_refused", "crates/ui-egui/src/web_print.rs::large_sheets_are_drawn_within_the_pixel_cap_and_bad_sizes_are_refused", "crates/ui-egui/src/web_print.rs::a_sink_error_or_a_page_that_isnt_there_stops_the_job", "crates/print/src/tests.rs::a_command_that_never_answers_is_stopped_at_the_deadline", "crates/ui-egui/tests/it/print.rs::printer_options_are_read_off_the_gui_thread_and_a_dead_read_offers_nothing"] -notes = "CUPS queues via lpstat -p -d and driverless destinations without a queue via lpstat -e; on Windows shared craft-print lists the queues (EnumPrinters), the default (GetDefaultPrinter) and each driver's two-sided support in this process, and draws jobs as vectors with Windows.Data.Pdf into the XPS print API with a driver-validated PrintTicket (exact paper, orientation, no scaling; portrait and landscape sheets in one job via /Rotate), starting no PowerShell, so printing works under App Control script enforcement (#1107); the printer list and submission run on workers; Save as PDF on every platform. Properties…: on CUPS the driver's own PPD job options (tray, paper type, finishing …, not installable options or what the dialog sets) for this job, sent as lp -o; on Windows the driver's Printing Preferences window (printui), saved as that printer's per-user defaults rather than per job. On the web: \"Browser's print dialog\" (the default there) reaches the system's printers through the browser; PdfCraft draws the sheets itself (300 dpi up to 100 sheets, 200 up to 300, 150 up to 1,000; at most 64 megapixels a sheet and 1 GiB a job) and prints them from a hidden iframe, one per printed page, a landscape sheet among portrait ones turned onto the paper; copies, two-sided and colour are chosen in the browser's dialog; a browser that refuses to print gets the print-ready PDF as a download. Checked in headless Chromium, not yet in Firefox or Safari; pictures, not vectors; sheets of another paper size print at their own size only where the browser honours named @page sizes (Chromium doesn't). Properties… on CUPS reads the driver options off the GUI thread and gives lpoptions ten seconds before the PPD defaults stand (#1818)" +evidence = ["crates/print/src/tests.rs::spooler_arguments_and_printer_list", "crates/print/src/tests.rs::a_job_names_its_title_and_owner_to_the_spooler", "crates/print/src/tests.rs::a_job_keeps_the_title_and_owner_it_already_sets", "crates/print/src/tests.rs::a_submitted_job_reaches_lp_named", "crates/print/src/tests.rs::driverless_printers_join_the_list_without_a_queue", "crates/print/src/tests.rs::windows_printers_carry_one_default_and_keep_their_names", "crates/print/src/tests.rs::printer_options_come_from_the_ppd", "crates/print/src/tests.rs::printer_option_defaults_and_job_arguments", "crates/ui-egui/tests/it/print.rs::print_dialog_shows_the_printer_driver_options", "crates/ui-egui/tests/it/print.rs::printers_listed_in_the_background_preselect_the_default_unless_one_was_picked", "crates/ui-egui/src/workflows/web_print.rs::resolution_falls_with_the_sheet_count_and_long_jobs_are_refused", "crates/ui-egui/src/workflows/web_print.rs::large_sheets_are_drawn_within_the_pixel_cap_and_bad_sizes_are_refused", "crates/ui-egui/src/workflows/web_print.rs::a_sink_error_or_a_page_that_isnt_there_stops_the_job", "crates/print/src/tests.rs::a_command_that_never_answers_is_stopped_at_the_deadline", "crates/ui-egui/tests/it/print.rs::printer_options_are_read_off_the_gui_thread_and_a_dead_read_offers_nothing"] +notes = "CUPS queues via lpstat -p -d and driverless destinations without a queue via lpstat -e; on Windows shared craft-print lists the queues (EnumPrinters), the default (GetDefaultPrinter) and each driver's two-sided support in this process, and draws jobs as vectors with Windows.Data.Pdf into the XPS print API with a driver-validated PrintTicket (exact paper, orientation, no scaling; portrait and landscape sheets in one job via /Rotate), starting no PowerShell, so printing works under App Control script enforcement (#1107); the printer list and submission run on workers; Save as PDF on every platform. Properties…: on CUPS the driver's own PPD job options (tray, paper type, finishing …, not installable options or what the dialog sets) for this job, sent as lp -o; on Windows the driver's Printing Preferences window (printui), saved as that printer's per-user defaults rather than per job. On the web: \"Browser's print dialog\" (the default there) reaches the system's printers through the browser; PdfCraft draws the sheets itself (300 dpi up to 100 sheets, 200 up to 300, 150 up to 1,000; at most 64 megapixels a sheet and 1 GiB a job) and prints them from a hidden iframe, one per printed page, a landscape sheet among portrait ones turned onto the paper; copies, two-sided and colour are chosen in the browser's dialog; a browser that refuses to print gets the print-ready PDF as a download. Checked in headless Chromium, not yet in Firefox or Safari; pictures, not vectors; sheets of another paper size print at their own size only where the browser honours named @page sizes (Chromium doesn't). Properties… on CUPS reads the driver options off the GUI thread and gives lpoptions ten seconds before the PPD defaults stand (#1818); on macOS lp jobs also carry the job name and owner (com.apple.print.JobInfo.PMJobName and PMJobOwner) that the system print dialog sends and some vendors' filters need (#5743). Not yet checked on a physical Kyocera printer." [[feature]] id = "print.copies-collate" @@ -8096,7 +8163,7 @@ milestone = "M10" status = "shipped" commands = ["print.dialog"] tools = ["doc_print"] -evidence = ["crates/print/src/tests.rs::spooler_arguments_and_printer_list", "crates/ui-egui/src/print_ui.rs::two_sided_follows_the_printer_until_chosen", "crates/automation/src/printing.rs::duplex_printer_leaves_two_sided_to_the_printer"] +evidence = ["crates/print/src/tests.rs::spooler_arguments_and_printer_list", "crates/ui-egui/src/workflows/print_ui.rs::two_sided_follows_the_printer_until_chosen", "crates/automation/src/printing.rs::duplex_printer_leaves_two_sided_to_the_printer"] notes = "long/short edge via the spooler: lp -o sides=…, and the Windows driver's Duplex setting. Two-sided starts at Printer default, as in Acrobat: nothing is sent, so the printer's own default (its Properties) applies until Off or an edge is chosen (doc_print: duplex \"printer\"). Missing: the document's /ViewerPreferences /Duplex preset isn't read yet" [[feature]] @@ -9180,8 +9247,8 @@ milestone = "M12" status = "shipped" commands = [] tools = ["view_reading_colors"] -evidence = ["crates/automation/tests/it/automation.rs::reading_colors_through_tools", "crates/render/src/raster.rs::reading_colors_map_solid_paints_and_leave_images", "crates/render/src/raster.rs::reading_colors_tiles_match_full_render", "crates/render/src/raster.rs::print_preview_requests_render_with_reading_colors_off", "crates/render/src/raster.rs::reading_colors_keep_highlight_annotations_readable", "crates/ui-egui/tests/it/reading_colors.rs::reading_colours_preference_round_trips", "crates/ui-egui/tests/it/reading_colors.rs::reading_colours_seed_on_open", "crates/ui-egui/tests/it/reading_colors.rs::pending_pages_use_document_reading_background"] -notes = "View only, off by default: Preferences ▸ Reading colours holds the app-wide preference, seeded into every document as it opens (including created ones), and view_reading_colors reads and sets it per document (Acrobat's Replace Document Colors). Solid text paints and, by default, line-art paints (including stencil-mask image paints) are remapped by luminance — each lerps between the preset's text and background colour by how dark or light the original paint is — while images, shadings and tiling patterns pass through untouched. Annotations keep the colours they were given (both /AP appearances and the no-/AP Highlight fallback), so a Multiply highlight over a dark background shows the marked-up words in the highlight's colour instead of a flat bar that hides them (#1548). Presets: black on white, white on black, yellow on black, green on black, or a custom #RRGGBB pair; only_black limits the mapping to near-black paints (a 10% luminance threshold — our choice; Acrobat's was not observed). Display only: the document is not changed and print and export are unaffected; print previews and view snapshots render with the document's true colours (a preview matches the uncoloured printout, and a snapshot is an export surface), so reading colours apply to the reading view only. Pending canvas pages use the configured reading background before their raster arrives (#2059). Closes #897 (duplicate #488)." +evidence = ["crates/automation/tests/it/automation.rs::reading_colors_through_tools", "crates/render/src/raster/tests.rs::reading_colors_map_solid_paints_and_leave_images", "crates/render/src/raster/tests.rs::reading_colors_tiles_match_full_render", "crates/render/src/raster/tests.rs::print_preview_requests_render_with_reading_colors_off", "crates/render/src/raster/tests.rs::reading_colors_keep_highlight_annotations_readable", "crates/ui-egui/tests/it/reading_colors.rs::reading_colours_preference_round_trips", "crates/ui-egui/tests/it/reading_colors.rs::reading_colours_seed_on_open", "crates/ui-egui/tests/it/reading_colors.rs::pending_pages_use_document_reading_background", "crates/ui-egui/tests/it/reading_colors.rs::reading_presets_follow_appearance_and_do_not_change_the_interface_theme", "crates/ui-egui/tests/it/theme_button.rs::preferences_gear_is_right_of_theme_and_opens_preferences"] +notes = "View only, off by default: Preferences ▸ Reading colours, directly below Appearance, offers colour samples and a live preview; the gear beside the theme button opens Preferences. Reading colours holds the app-wide preference, seeded into every document as it opens (including created ones), and view_reading_colors reads and sets it per document (Acrobat's Replace Document Colors). Solid text paints and, by default, line-art paints (including stencil-mask image paints) are remapped by luminance — each lerps between the preset's text and background colour by how dark or light the original paint is — while images, shadings and tiling patterns pass through untouched. Annotations keep the colours they were given (both /AP appearances and the no-/AP Highlight fallback), so a Multiply highlight over a dark background shows the marked-up words in the highlight's colour instead of a flat bar that hides them (#1548). Presets: black on white, white on black, yellow on black, green on black, or a custom #RRGGBB pair; only_black limits the mapping to near-black paints (a 10% luminance threshold — our choice; Acrobat's was not observed). Display only: the document is not changed and print and export are unaffected; print previews and view snapshots render with the document's true colours (a preview matches the uncoloured printout, and a snapshot is an export surface), so reading colours apply to the reading view only. Pending canvas pages use the configured reading background before their raster arrives (#2059). Closes #897 (duplicate #488)." [[feature]] id = "a11y.reflow-tagged" @@ -9892,8 +9959,8 @@ milestone = "M14" status = "partial" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/it/view.rs::default_page_display_is_used_for_new_documents", "crates/ui-egui/tests/it/view.rs::default_zoom_is_used_for_new_documents", "crates/ui-egui/tests/it/view.rs::preferences_set_the_default_zoom", "crates/ui-egui/tests/it/view.rs::always_use_my_default_zoom_and_page_layout_ignores_what_the_pdf_asks_for"] -notes = "Preferences ▸ Documents and view: Default page display and Default zoom (Fit to width, Zoom to page level or a percentage) for every new tab; a PDF that asks for a layout or zoom gets it, unless Always use my default zoom and page layout is on. Missing: Acrobat's resolution, rendering and page-content options" +evidence = ["crates/ui-egui/tests/page_shadows.rs::viewport_fades_follow_the_preference_and_keep_other_scroll_areas_styled", "crates/ui-egui/tests/page_shadows.rs::rendered_page_content_is_identical_with_and_without_decoration", "crates/ui-egui/tests/page_shadows.rs::page_shadow_preference_round_trips_and_ignores_invalid_settings", "crates/ui-egui/tests/page_shadows.rs::viewer_shadows_follow_the_preference_without_moving_pages", "crates/ui-egui/tests/page_shadows.rs::organize_shadows_follow_the_preference", "crates/ui-egui/tests/it/control.rs::page_shadows_are_an_observable_app_wide_preference", "crates/ui-egui/tests/it/view.rs::default_page_display_is_used_for_new_documents", "crates/ui-egui/tests/it/view.rs::default_zoom_is_used_for_new_documents", "crates/ui-egui/tests/it/view.rs::preferences_set_the_default_zoom", "crates/ui-egui/tests/it/view.rs::always_use_my_default_zoom_and_page_layout_ignores_what_the_pdf_asks_for"] +notes = "Preferences ▸ Documents and view: Default page display and Default zoom (Fit to width, Zoom to page level or a percentage) for every new tab; a PDF that asks for a layout or zoom gets it, unless Always use my default zoom and page layout is on. Preferences ▸ Appearance also has an app-wide, persistent Show Page Edge Shadows checkbox (on by default), exposed through ui.set and ui.state; it affects viewer and Organize decorations, including the viewport-edge gradients painted over pages, only. Missing: Acrobat's resolution, rendering and page-content options" [[feature]] id = "misc.prefs-documents" @@ -9901,11 +9968,11 @@ title = "Preferences: documents (autosave interval, recent count)" area = "N" tier = "P1" milestone = "M14" -status = "planned" +status = "partial" commands = [] tools = [] -evidence = [] -notes = "" +evidence = ["crates/ui-egui/tests/it/recent_files.rs::preferences_cuts_the_list_when_it_closes_and_not_before", "crates/ui-egui/tests/it/recent_files.rs::at_zero_home_has_no_recent_section", "crates/ui-egui/tests/it/recent_files.rs::a_settings_file_can_not_ask_for_a_limit_that_is_not_one"] +notes = "Preferences ▸ Documents and view: Recent files to keep, from 0 to 50 with 12 by default (#5572). At 0 Home has no Recent section and File ▸ Open Recent is disabled; Restore the last view keeps its own record of paths under its own switch. Still missing: the autosave interval (recovery snapshots run every AUTOSAVE_SECS, 60 s) and the rest of Acrobat's Documents page." [[feature]] id = "misc.prefs-commenting" @@ -10012,7 +10079,7 @@ 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/it/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_catalog_stays_logical", "crates/ui-egui/src/i18n/mod.rs::arabic_plurals_follow_the_six_cldr_forms", "crates/ui-egui/src/i18n/mod.rs::arabic_labels_have_glyphs", "crates/ui-egui/src/bidi.rs::arabic_ui_labels_use_an_explicit_paragraph_direction", "crates/ui-egui/src/theme.rs::arabic_chrome_is_right_to_left_after_the_language_changes", "crates/ui-egui/tests/it/control.rs::simplified_chinese_signature_prompts_and_errors_keep_document_state", "crates/ui-egui/tests/it/control.rs::language_switch_preserves_document_and_command_ids", "crates/ui-egui/tests/it/control.rs::japanese_controls_and_search_keep_command_ids", "crates/ui-egui/tests/it/control.rs::japanese_dialogs_errors_and_custom_action_names", "crates/ui-egui/src/i18n/mod.rs::french_is_registered", "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/it/control.rs::german_preferences_and_search_keep_command_ids", "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::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::spanish_keeps_the_holes_in_what_the_engine_says", "crates/print/src/tests.rs::wordings_are_said", "crates/ui-egui/src/i18n/mod.rs::norwegian_bokmal_is_registered_and_persists"] +evidence = ["crates/ui-egui/src/commands.rs::localized_shortcut_labels_follow_language_and_platform", "crates/ui-egui/src/i18n/tests.rs::keyboard_key_names_have_a_separate_context_and_english_fallback", "crates/ui-egui/tests/it/commands.rs::localized_shortcuts_update_immediately_and_keep_command_ids_and_key_bindings", "crates/ui-egui/src/i18n/tests.rs::windows_display_language_resolves_chinese_scripts", "crates/ui-egui/src/i18n/tests.rs::neutral_locale_does_not_override_display_language", "crates/ui-egui/src/i18n/tests.rs::windows_display_language_query_returns_a_locale_tag", "crates/ui-egui/src/i18n/tests.rs::language_persists_and_invalid_input_keeps_current_language", "crates/ui-egui/src/i18n/tests.rs::bundled_catalogs_are_consistent", "crates/ui-egui/src/i18n/tests.rs::arabic_is_registered_and_persists", "crates/ui-egui/src/i18n/tests.rs::arabic_catalog_stays_logical", "crates/ui-egui/src/i18n/tests.rs::arabic_plurals_follow_the_six_cldr_forms", "crates/ui-egui/src/i18n/tests.rs::arabic_labels_have_glyphs", "crates/ui-egui/src/bidi/tests.rs::arabic_ui_labels_use_an_explicit_paragraph_direction", "crates/ui-egui/src/theme/tests.rs::arabic_chrome_is_right_to_left_after_the_language_changes", "crates/ui-egui/tests/it/control.rs::simplified_chinese_signature_prompts_and_errors_keep_document_state", "crates/ui-egui/tests/it/control.rs::language_switch_preserves_document_and_command_ids", "crates/ui-egui/tests/it/control.rs::japanese_controls_and_search_keep_command_ids", "crates/ui-egui/tests/it/control.rs::japanese_dialogs_errors_and_custom_action_names", "crates/ui-egui/src/i18n/tests.rs::french_is_registered", "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/it/control.rs::german_preferences_and_search_keep_command_ids", "crates/ui-egui/src/i18n/tests.rs::telugu_labels_have_glyphs", "crates/ui-egui/src/i18n/tests.rs::hungarian_is_registered", "crates/ui-egui/src/i18n/tests.rs::ukrainian_is_registered_and_persists", "crates/ui-egui/src/i18n/tests.rs::ukrainian_integer_plurals_cover_teens_and_large_counts", "crates/ui-egui/src/i18n/tests.rs::spanish_keeps_the_holes_in_what_the_engine_says", "crates/print/src/tests.rs::wordings_are_said", "crates/ui-egui/src/i18n/tests.rs::norwegian_bokmal_is_registered_and_persists"] 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/Norwegian Bokmål and persists; ui.set language auto|en|ja|zh-hans|zh-hant|cs|pt-br|de|es|fr|te|hu|uk|it|ar|nb 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; Brazilian Portuguese covers the current union of catalog keys, including signature and accessibility messages; Czech remains limited to 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, and Norwegian locales (nb, nb_NO, and no/nn until a Nynorsk catalog exists) the Bokmål 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, Italian and Brazilian Portuguese translate them, Polish the signature details and the changes after signing; strings absent from 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. Catalogs stay logical and egui 0.37's epaint applies UAX #9 reordering; Arabic and Kurdish labels take an explicit right-to-left paragraph direction and stock chrome is mirrored through Style::right_to_left. Missing: native and browser end-to-end RTL input, IME and complex bidirectional workflows are not yet qualified; Czech dialogs, Chinese and Telugu font delivery (native release builds embed LXGW WenKai Lite for Simplified Chinese, while the web build has no Chinese face beyond BIZ UDPGothic and the release-pinned craft-fonts has no Telugu face), the engine's sentences in the other catalogs, a full localization audit." [[feature]] @@ -10144,7 +10211,7 @@ milestone = "M14" status = "partial" commands = [] tools = [] -evidence = ["crates/ui-egui/src/i18n/mod.rs::bundled_catalogs_are_consistent", "crates/ui-egui/src/i18n/mod.rs::date_languages_are_the_interface_languages", "crates/ui-egui/src/i18n/mod.rs::language_persists_and_invalid_input_keeps_current_language"] +evidence = ["crates/ui-egui/src/i18n/tests.rs::bundled_catalogs_are_consistent", "crates/ui-egui/src/i18n/tests.rs::date_languages_are_the_interface_languages", "crates/ui-egui/src/i18n/tests.rs::language_persists_and_invalid_input_keeps_current_language"] notes = "The UI offers 22 non-English catalogs plus English fallback (entry counts vary by language), and the selected language persists. Partial because the languages are not all usable yet: Simplified Chinese has a native release LXGW WenKai Lite face but the web build has no Hans face (#826, #688), the release-pinned craft-fonts has no Telugu face, Arabic labels stay logical with explicit right-to-left direction and mirrored stock chrome while native/browser/IME RTL workflows are not yet qualified (misc.rtl-ui), and strings missing from a catalog fall back to English (localization-parity.md). The language can be set through the UI control channel (`set language`) but not through an automation tool, so pdfcraft-cli run and MCP cannot change it." [[feature]] diff --git a/vendor/README.md b/vendor/README.md index 20fb0c75a8..4fda6483af 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -39,11 +39,11 @@ Other resource categories retain their existing parent-chain lookup. | 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 ş; the comma-below letters (`/scommaaccent`, `/Tcommaaccent`, `/kcommaaccent`…, found by character, so `/uni0136` and the glyph list's `/Kcedilla` too) are the letter and the font's comma, smaller and under the baseline, since the stand-ins have no `commaaccent` (Romanian ș drew nothing and ț a cedilla in forms laid out from XFA; ģ, whose comma is turned and above, is not built); (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; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548); (30) the Annex D.2 alias codes read as the ink they draw: WinAnsiEncoding 0xA0 and 0xAD, and MacRomanEncoding 0xCA, carry the glyph names `space` and `hyphen` -- the names those tables also give 0x20 and 0x2D -- not `nbspace` and `sfthyphen`, whose glyph list values U+00A0 and U+00AD are invisible formatting characters. Extraction hid drawn ink behind them, so search and redaction matched neither (`XiaoBiaoSong.pdf` in the pdf.js corpus drew 104 soft hyphens; across the 989 files the soft hyphens we extract fall from 117 to 13 and the non-breaking spaces from 30 to 26, what remains coming from `/ToUnicode` maps, which say so explicitly). The base table only, in the text direction only: a `/Differences` name keeps its glyph list value and glyph lookup is untouched, both as pdftotext does; (31) a Type 0 sampled function keeps its samples in one flat array indexed by strides (`Type0::entry`) instead of a hash map keyed by each grid point: the map was built again, one hashed insert per sample, every time a shading using the function was painted (a 2,097,152-sample ramp painted 30 times took 38 s; `bug1721218` in the pdf.js corpus spent most of a 1.5 s page on it); the interpolation is unchanged, so results are bit for bit the same, a sample missing from a short stream still gives no value, and the maximum sample value is computed without overflowing at 32 bits per sample; (32) the decoded-image cache (24) keys a decode by the size the image is drawn at only when a JPX stream decodes it or its soft mask or mask (`decode_reads_target`): JPX is the only filter that decodes a reduced resolution for a size, so a JPEG or Flate scan was decoded again at every zoom step and kept once per zoom level; one decode now serves every size; `InterpreterCache::decoded_image_decodes` counts the cache's decodes; (33) Coons and tensor-product patches copy their vertices' colours less: each point of a patch's 20 × 20 triangle grid is interpolated once (`generate_patch_triangles`; each cell interpolated its four corners itself, 1,444 interpolations a patch for 400 points), a cell's second triangle takes the vertices its first copied, and the mesh sampler moves a patch's triangles into device space instead of copying each vertex (`TriangleSampler::sample_owned`); the same values, 10–16% faster on mesh-gradient pages; (34) `color::tint_transform_runs`: how many colours the calling thread has run through a Separation or DeviceN tint transform (counted once per batch in `convert_f32`), so a test counts patch (28)'s once-per-sample-value runs instead of timing them (its 10 s deadline failed on a busy FreeBSD VM in CI); (35) `EncodedShadingPattern::extent`: where a mesh shading (types 4–7) can paint at all, the pixels its triangles touched when it was encoded (`None` for the types that paint everywhere, and when the shading has a `/Background`), so the renderer samples a texture there alone and skips a mesh with nothing on the canvas; (36) a mesh shading's pixel whose corner no triangle holds strictly (on an edge two triangles share, or on the mesh's side) takes the triangle that covers its centre, edges included (`Triangle::covers`): testing only the corner left a white seam along every shared edge, and the colour is still taken at the corner; (38) the `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` colour spaces of a resource dictionary (ISO 32000-2 §8.6.5.6) replace the device space content selects: with `g`/`G`, `rg`/`RG`, `k`/`K` and `cs`/`CS` (by name, or through a `/ColorSpace` resource that is a device space), and as the `/ColorSpace` of an image XObject, an inline image or a shading painted by `sh` or as a shading pattern (`DefaultColorSpaces`, looked up once per content window in the resources and those they inherit, so a form XObject without a default of its own draws in its page's, as Poppler and MuPDF draw it); a default that is a Lab, Indexed, Pattern or device space, has another number of components or is no colour space is ignored and device colour is drawn as before; device spaces inside other spaces (an Indexed base, a Separation or DeviceN alternate, a Pattern's underlying space), shading dictionaries, masks and transparency group spaces are unchanged, and the decoded-image cache (24) keys a decode by the default it was drawn in. hayro drew device colour as it was, so pages that calibrate it this way (common in PDF/A) came out in the wrong colours; (39) knockout groups (#2971): `Device::set_knockout` right after a `/K true` group is pushed; `Device::knockout_image` for an unmasked image before its group is pushed, when `Device::in_knockout_group`: the device clears its placement rectangle only if it will draw the image (on the canvas, not a preview's tiny image, and it decodes), decoded at its draw size so the draw reuses that decode; and, when `Device::knockout_erase_wanted`, a nested group's content drawn once between `begin_knockout_erase` and `end_knockout_erase` before the group itself, which then always gets a layer of its own; (40) an optional content membership dictionary's visibility expression (`/VE`: `/And`, `/Or`, `/Not`, nested up to 32 deep) decides its visibility ahead of `/OCGs` and `/P` (ISO 32000-2 §8.11.2.2); it was ignored, so content under it stayed visible with its layers hidden; a malformed expression, including one naming an object the document doesn't list in `/OCProperties /OCGs`, falls back to `/OCGs` and `/P` (#2986); (41) 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); (42) 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 (`7 Tr` since follows 64); (43) 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`; (44) a Type 0 font whose `/ToUnicode` is the name `/Identity-H` or `/Identity-V` (not a CMap stream; WSCAD writes it) reads each code as its Unicode value, as Acrobat shows it (`Type0Font::identity_to_unicode`); the identity CMap the name resolved to maps codes to CIDs only, so such pages had no text to select or find; (46) an image XObject's group applies the fill opacity, so the fill alpha is reset to 1 before a stencil mask takes the fill colour as its paint, and a pattern paint drops the opacity it captured at `scn` (`Pattern::without_opacity`) (it was applied twice: a mask at `ca 0.5` was drawn at a quarter, #2975); (47) `path_as_rect`: a five-element path is a rectangle only when its four points are distinct, each step from one to the next runs along a single axis, and the last returns to the first. The length test admits one MoveTo and four LineTo as well as one MoveTo, three LineTo and a ClosePath, and asking only that every corner of the bounding box was visited says nothing about the order or the steps between, so a five-point polyline whose ends reached the corners was painted as that box -- a fourth side the path does not have -- and a clip of that shape was discarded; the coordinates are compared exactly, since the matrix is applied after this check and a `cm` of 10,000 turns `is_nearly_equal`'s 256th of a unit into tens of device units (#3327); (48) an `/ICCBased` colour space is cached by what its profile is read from (`IccProfileKey`): the stream object it names, looked up without reading the stream, and then the stream's dictionary and its own bytes (decrypted, not decoded), so a byte-for-byte copy of a profile in another object is read once, and a profile stream written directly in its array, which reports the object holding the array as every other stream written there does, is told apart by its bytes. hayro keyed it by the stream dictionary's bytes alone, so two different profiles with the same `/N` and `/Length` shared whichever was read first, and colours in the second were drawn through the first. `InterpreterCache::icc_profile_reads` counts the profiles read; (49) `OpenTypeFontBlob::new` also accepts a `post` table version 1.0, 2.0 or 3.0 written with its 16-bit halves swapped (`00 00 00 03`), as ScanSoft PDF Create! embeds every font; such fonts were refused and their text drawn with a substitute (Hebrew came out garbled or missing, #2946); `glyph_names` reads such a table from a copy with the version written correctly, so a 2.0 table's names serve `/Differences`. Any other unknown version is still refused (pdf.js 9462); (50) `CffFontBlob::glyph_index_by_name` looks a glyph name up in a name to glyph map built on first use (shared between clones) instead of searching the whole charset, with a string lookup per glyph, for every character code of a font; the first glyph with a name still wins; (51) at most 4096 graphics states saved at once, holding at most 64 MiB together (each `q` copies the whole state, dash array and clip list included, so short operators multiplied one large state); a `q` past either limit is counted but not copied, and its `Q` still removes the clips added since (from storytold/craft-libs#239); (52) a `/W2` run whose first CID is past `MAX_CID` stops there, as in `/W` (7), checked before the run's metrics are read: a first CID of 4294967295 inserted one entry and then overflowed, discarding the whole `/W2` table (from storytold/craft-libs#254); (55) a Separation or DeviceN tint transform that cannot be read keeps its colour space, and one that cannot be read or evaluated paints the alternate space's initial colour, as evaluation failures already did; that fallback, and a `cs`/`CS` colour space that cannot be read (drawn in DeviceGray as before), are now reported once per content stream as `InterpreterWarning::ColorFallback` (`RenderWarning::ColourFallback` in `pdfcraft-render`) instead of silently (#4260); (56) an annotation whose Invisible flag (bit 1) is set and whose `/Subtype` is none of the standard types of ISO 32000-2 Table 171 (`is_standard_annotation_type`; no `/Subtype` counts as unknown) is not drawn (Table 167), and a `/Popup` annotation's appearance is never painted on the page (§12.5.6.14: a popup has no appearance stream of its own; the viewer shows it as a window for its parent annotation). Both were drawn from their `/AP`; (57) a simple font drawn with a standard-font stand-in because the document names a font it doesn't embed (`StandardKind` with `fallback`) narrows a glyph to a narrower `/Widths` advance but no longer widens it to a wider one: the advance already places the next glyph (ISO 32000-2 §9.2.4; /Widths in Table 109), and widening drew a non-embedded `Arial,Bold` with 600-unit widths as a heavy fixed-pitch face whose `i` and `l` were solid blocks twice their width; an exact standard-14 font still matches its standard metrics both ways; (58) the fallback request for a Type 0 font that is not embedded (`FallbackFontQuery::with_descriptor`) takes its serif, italic and small-cap flags, `/FontWeight`, `/FontFamily` and `/FontStretch` from the descendant CIDFont's font descriptor (ISO 32000-2 §9.7.4.1, §9.7.6.1; §9.8): it read them from the Type 0 dictionary, which has none, so every such request asked for a regular sans-serif face and no resolver could tell a Song or Mincho font from a Hei or Gothic one, or see `/FontWeight 700`; (59) the NoRotate flag (`/F` bit 5) is honoured: the appearance, fitted to `/Rect`, is turned against the page's `/Rotate` about the rectangle's upper-left corner, which stays where it is, so it reads upright as the page is displayed (ISO 32000-2 §12.5.3). Sticky notes, which are saved NoRotate, were drawn turned with the page (#2583). Form fields and links stay in `/Rect`, where PdfCraft takes their click targets from it; (64) text in a Type 3 font follows ISO 32000-2 §9.3.6 in the clipping modes: under `7 Tr` it is not rendered (reported to the device as clipping text, `GlyphDrawMode::Clip`, so extraction keeps it), and in modes 4 to 7 a Type 3 glyph adds nothing to the clipping path (`clip_glyph`), so text shown only in a Type 3 font leaves no clip at `ET`. Since (42) `7 Tr` painted the glyphs in the fill colour (black by default), so a fill meant to follow showed them wherever it didn't cover them; 4 to 6 still render filled; (66) a Separation colour space whose colourant is the special name `All` paints every colourant at once (ISO 32000-2 §8.6.6.4): on screen and in an RGB print raster its tint t is the gray 1 − t, complemented because the device is additive, whatever its alternate space and tint transform say (hayro drew it through the tint transform, so a `/DeviceCMYK` transform giving cyan drew registration marks light cyan); `/None` still paints nothing, any other name still goes through its alternate, and a DeviceN space naming `All` keeps its alternate, since the clause gives the name that meaning in a Separation space; (63) a luminosity soft mask whose group colour space is `/DeviceCMYK` (by name, not an ICC-based or calibrated space) reads a `/DeviceCMYK` fill or stroke colour drawn in it, and its `/BC`, as their gray `1 - min(1, 0.3 c + 0.59 m + 0.11 y + k)` (ISO 32000-2 §10.4.2.3, §11.5.3: device colour converts to gray with no colour calibration; `Context::luminosity_in_device_cmyk`, `color::device_cmyk_to_gray`): it went through the CMYK display profile, where 100% K is a dark grey, so a 100% K mask let about an eighth of the masked paint through. CMYK images, shadings and tiling patterns inside such a mask, and CMYK on the page, still use the profile | (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) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`, `raster::tests::comma_below_letters_in_standard_fonts_draw_a_comma_under_the_letter`; (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`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (30) `text::tests::the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space`; (31) `raster::tests::shadings_with_large_sampled_functions_paint_quickly`, `raster::tests::sampled_functions_interpolate_their_grid_as_before`; (32) `raster::tests::decoded_images_serve_every_size_they_are_drawn_at`; (33) `raster::tests::patch_meshes_keep_their_corner_colours`; (34) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`; (35) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (36) `raster::tests::mesh_triangles_sharing_an_edge_leave_no_seam`; (38) `raster::tests::default_colour_spaces::device_rgb_operators_draw_in_default_rgb`, `raster::tests::default_colour_spaces::device_cmyk_operators_draw_in_default_cmyk`, `raster::tests::default_colour_spaces::device_gray_operators_draw_in_default_gray`, `raster::tests::default_colour_spaces::device_images_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::device_shadings_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::an_image_drawn_with_and_without_a_default_keeps_both_colours`, `raster::tests::default_colour_spaces::forms_inherit_the_default_colour_spaces_they_do_not_override`, `raster::tests::default_colour_spaces::unusable_defaults_leave_device_colour_alone`; (39) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (40) `raster::tests::visibility_expressions_decide_what_a_membership_dictionary_shows`; (41) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (42) `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`; (43) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours`; (44) `text::tests::identity_to_unicode_names_give_text`; (46) `raster::tests::stencil_masks_are_drawn_at_the_fill_opacity_once`; (47) `raster::tests::a_five_point_polyline_is_not_painted_as_its_bounding_box`, `raster::tests::a_path_that_visits_every_corner_out_of_order_is_not_a_rectangle`, `raster::tests::a_rectangle_is_still_painted_as_a_rectangle`; (48) `raster::tests::icc_profile_cache::icc_profiles_with_identical_dictionaries_draw_their_own_colours`, `raster::tests::icc_profile_cache::icc_profiles_used_again_are_read_once`; (49) `raster::tests::a_font_whose_post_version_has_swapped_halves_is_still_used`; (50) `font::blob::tests::glyph_names_resolve_to_the_first_glyph_with_that_name`; (51) `context::saved_state_tests::repeated_saves_stay_bounded_and_balanced`; (52) `font::cid::tests::w2_run_past_the_largest_cid_keeps_the_rest_of_the_table` (hayro-interpret); (55) `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_evaluated_draws_the_rest_of_the_page_and_warns`, `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_read_falls_back_and_warns`; (56) `raster::tests::invisible_unknown_annotations_and_popups_paint_nothing`; (57) `substitute_widths::a_substitute_keeps_its_glyph_shapes_at_wider_advances`, `substitute_widths::a_substitute_still_narrows_glyphs_to_narrower_advances` (pdfcraft-render `tests/it`); (58) `cid_fallback_query::a_cid_font_fallback_request_carries_its_descendants_style`, `cid_fallback_query::a_cid_font_without_a_descriptor_asks_for_the_default_style` (pdfcraft-render integration tests); (59) `raster::tests::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages` (hayro-interpret); (64) `type3_text_clip::{clip_only_type3_text_is_not_rendered_and_leaves_no_clip, invisible_type3_text_is_not_rendered, other_modes_render_type3_glyphs_filled_and_never_clip, only_outline_glyphs_shape_a_clip_shared_with_type3_text, self_nesting_type3_text_in_a_clip_mode_stays_bounded}` (`cargo test -p pdfcraft-render --test it`), `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`, `text::tests::clipping_mode_type3_text_is_kept_by_the_ocr_filter`; (66) `separation_all::{separation_all_fills_paint_the_complemented_tint_as_gray, separation_all_strokes_paint_the_complemented_tint_as_gray, separation_all_images_paint_each_sample_as_the_complemented_gray, separation_all_shadings_paint_the_complemented_gray, separation_none_paints_nothing, a_named_separation_still_paints_through_its_alternate, a_device_n_space_naming_all_still_paints_through_its_alternate}` (`cargo test -p pdfcraft-render --test it`); (63) `color::device_cmyk_gray_tests::{each_ink_darkens_by_its_iso_weight, ink_past_full_coverage_is_black_and_bad_components_read_as_zero}` (hayro-interpret), `it::luminosity_group_space::{black_ink_in_a_device_cmyk_group_masks_fully_and_no_ink_shows_fully, each_process_ink_in_a_device_cmyk_group_masks_by_its_weight, a_device_cmyk_backdrop_colour_masks_by_its_gray, gray_and_rgb_groups_mask_as_before, device_cmyk_on_the_page_keeps_its_display_colour}` (pdfcraft-render) | -| 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; (17) the renderer keeps annotation paints out of the reading-colours mapping between `begin_annotations` and `end_annotations` (#1548); (18) the page renders as one transparency group; under reading colours its background is painted inside it as the initial backdrop, so a Multiply highlight blends with the dark background instead of staying an opaque block, and otherwise the background stays outside, so a blend mode over an empty part of the page keeps its colour instead of blending with the white paper (#2970); (19) `set_paint` samples a mesh shading's texture over its triangles alone (`EncodedShadingPattern::extent`, padded rather than repeated) and skips a mesh with none on the canvas: a BricsCAD plan's 310 one-triangle `sh` each covered the whole tile, 2 s per 1024 px tile at any zoom (the watchdog gave the page up on a busy machine), now about 70 ms; (20) a tiling pattern whose step is too large for a cell pixmap, where what is painted (a fill, or a stroke with its width) can only show the first cell (PowerPoint's `/XStep 2147480100` for a picture fill drawn once), has its `/BBox` drawn at full scale with a transparent border and padded instead of tiled (`shows_one_cell`; a cell whose scale isn't finite and positive, from a `/BBox` too thin to divide by, keeps the old path: `usable_cell_scale`); shrinking the cell to fit the 3000 px limit of (3) left the picture under a pixel, so network diagrams lost their pictures (#3349); (22) knockout groups: while one is innermost, fills, strokes, glyph outlines and shadings are drawn with `Compose::Copy` (weighted by coverage), so each element replaces what earlier elements of the group drew instead of compositing over it; an image or nested group, drawn into a layer of its own, first has what is under its shape cleared (`knockout_image` for an unmasked image's rectangle, only when `draw_image` will draw it, decoded at the size it is drawn; a knockout erase pass that draws a nested group's content with every paint clearing instead of painting, a shading fill's clear clipped to the shading's `/BBox` as its paint is) and then composites over it (#2971); (23) 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; (25) a tiling cell is drawn at the scale that makes one `/XStep` (`/YStep`) exactly the cell pixmap's whole number of pixels (`tiling_cell_pixels`, at least 1 and at most the cap), so the repeated pixmap puts cells at whole multiples of the step at any device scale; drawing at the unrounded scale repeated the cell up to half a pixel off at every step, and the error grew with distance from the pattern's origin (ISO 32000-2 8.7.3.1) | (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`; (17), (18) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (19) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (20) `raster::tests::a_tiling_pattern_drawn_once_shows_its_cell`, `raster::tests::a_large_step_still_tiles_where_the_next_cell_shows`, `raster::tests::a_tiling_cell_too_thin_to_scale_keeps_the_old_path`; (22) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (25) `tiling_cell_placement::{far_cells_of_a_fill_sit_at_whole_steps, far_cells_sit_at_whole_steps_under_a_turned_scaled_matrix, far_cells_of_a_stroke_sit_at_whole_steps, a_cell_pixmap_spans_exactly_one_step}` (`cargo test -p pdfcraft-render --test it`) | -| 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); (4) a segment header's long-form referred-to segment count is capped at `u16::MAX` and a larger count is refused before its retention or referred-to bytes are read: the 29-bit count went straight to `Vec::with_capacity`, a reservation of about 2 GiB whose failure aborts the process (from storytold/craft-libs#176) | (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`; (4) `segment::tests::test_referred_to_count_is_capped` (`cargo test --manifest-path vendor/hayro-jbig2/Cargo.toml`) | +| 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 ş; the comma-below letters (`/scommaaccent`, `/Tcommaaccent`, `/kcommaaccent`…, found by character, so `/uni0136` and the glyph list's `/Kcedilla` too) are the letter and the font's comma, smaller and under the baseline, since the stand-ins have no `commaaccent` (Romanian ș drew nothing and ț a cedilla in forms laid out from XFA; ģ, whose comma is turned and above, is not built); (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; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548); (30) the Annex D.2 alias codes read as the ink they draw: WinAnsiEncoding 0xA0 and 0xAD, and MacRomanEncoding 0xCA, carry the glyph names `space` and `hyphen` -- the names those tables also give 0x20 and 0x2D -- not `nbspace` and `sfthyphen`, whose glyph list values U+00A0 and U+00AD are invisible formatting characters. Extraction hid drawn ink behind them, so search and redaction matched neither (`XiaoBiaoSong.pdf` in the pdf.js corpus drew 104 soft hyphens; across the 989 files the soft hyphens we extract fall from 117 to 13 and the non-breaking spaces from 30 to 26, what remains coming from `/ToUnicode` maps, which say so explicitly). The base table only, in the text direction only: a `/Differences` name keeps its glyph list value and glyph lookup is untouched, both as pdftotext does; (31) a Type 0 sampled function keeps its samples in one flat array indexed by strides (`Type0::entry`) instead of a hash map keyed by each grid point: the map was built again, one hashed insert per sample, every time a shading using the function was painted (a 2,097,152-sample ramp painted 30 times took 38 s; `bug1721218` in the pdf.js corpus spent most of a 1.5 s page on it); the interpolation is unchanged, so results are bit for bit the same, a sample missing from a short stream still gives no value, and the maximum sample value is computed without overflowing at 32 bits per sample; (32) the decoded-image cache (24) keys a decode by the size the image is drawn at only when a JPX stream decodes it or its soft mask or mask (`decode_reads_target`): JPX is the only filter that decodes a reduced resolution for a size, so a JPEG or Flate scan was decoded again at every zoom step and kept once per zoom level; one decode now serves every size; `InterpreterCache::decoded_image_decodes` counts the cache's decodes; (33) Coons and tensor-product patches copy their vertices' colours less: each point of a patch's 20 × 20 triangle grid is interpolated once (`generate_patch_triangles`; each cell interpolated its four corners itself, 1,444 interpolations a patch for 400 points), a cell's second triangle takes the vertices its first copied, and the mesh sampler moves a patch's triangles into device space instead of copying each vertex (`TriangleSampler::sample_owned`); the same values, 10–16% faster on mesh-gradient pages; (34) `color::tint_transform_runs`: how many colours the calling thread has run through a Separation or DeviceN tint transform (counted once per batch in `convert_f32`), so a test counts patch (28)'s once-per-sample-value runs instead of timing them (its 10 s deadline failed on a busy FreeBSD VM in CI); (35) `EncodedShadingPattern::extent`: where a mesh shading (types 4–7) can paint at all, the pixels its triangles touched when it was encoded (`None` for the types that paint everywhere, and when the shading has a `/Background`), so the renderer samples a texture there alone and skips a mesh with nothing on the canvas; (36) a mesh shading's pixel whose corner no triangle holds strictly (on an edge two triangles share, or on the mesh's side) takes the triangle that covers its centre, edges included (`Triangle::covers`): testing only the corner left a white seam along every shared edge, and the colour is still taken at the corner; (38) the `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` colour spaces of a resource dictionary (ISO 32000-2 §8.6.5.6) replace the device space content selects: with `g`/`G`, `rg`/`RG`, `k`/`K` and `cs`/`CS` (by name, or through a `/ColorSpace` resource that is a device space), and as the `/ColorSpace` of an image XObject, an inline image or a shading painted by `sh` or as a shading pattern (`DefaultColorSpaces`, looked up once per content window in the resources and those they inherit, so a form XObject without a default of its own draws in its page's, as Poppler and MuPDF draw it); a default that is a Lab, Indexed, Pattern or device space, has another number of components or is no colour space is ignored and device colour is drawn as before; device spaces inside other spaces (an Indexed base, a Separation or DeviceN alternate, a Pattern's underlying space), shading dictionaries, masks and transparency group spaces are unchanged, and the decoded-image cache (24) keys a decode by the default it was drawn in. hayro drew device colour as it was, so pages that calibrate it this way (common in PDF/A) came out in the wrong colours; (39) knockout groups (#2971): `Device::set_knockout` right after a `/K true` group is pushed; `Device::knockout_image` for an unmasked image before its group is pushed, when `Device::in_knockout_group`: the device clears its placement rectangle only if it will draw the image (on the canvas, not a preview's tiny image, and it decodes), decoded at its draw size so the draw reuses that decode; and, when `Device::knockout_erase_wanted`, a nested group's content drawn once between `begin_knockout_erase` and `end_knockout_erase` before the group itself, which then always gets a layer of its own; (40) an optional content membership dictionary's visibility expression (`/VE`: `/And`, `/Or`, `/Not`, nested up to 32 deep) decides its visibility ahead of `/OCGs` and `/P` (ISO 32000-2 §8.11.2.2); it was ignored, so content under it stayed visible with its layers hidden; a malformed expression, including one naming an object the document doesn't list in `/OCProperties /OCGs`, falls back to `/OCGs` and `/P` (#2986); (41) 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); (42) 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 (`7 Tr` since follows 64); (43) 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`; (44) a Type 0 font whose `/ToUnicode` is the name `/Identity-H` or `/Identity-V` (not a CMap stream; WSCAD writes it) reads each code as its Unicode value, as Acrobat shows it (`Type0Font::identity_to_unicode`); the identity CMap the name resolved to maps codes to CIDs only, so such pages had no text to select or find; (46) an image XObject's group applies the fill opacity, so the fill alpha is reset to 1 before a stencil mask takes the fill colour as its paint, and a pattern paint drops the opacity it captured at `scn` (`Pattern::without_opacity`) (it was applied twice: a mask at `ca 0.5` was drawn at a quarter, #2975); (47) `path_as_rect`: a five-element path is a rectangle only when its four points are distinct, each step from one to the next runs along a single axis, and the last returns to the first. The length test admits one MoveTo and four LineTo as well as one MoveTo, three LineTo and a ClosePath, and asking only that every corner of the bounding box was visited says nothing about the order or the steps between, so a five-point polyline whose ends reached the corners was painted as that box -- a fourth side the path does not have -- and a clip of that shape was discarded; the coordinates are compared exactly, since the matrix is applied after this check and a `cm` of 10,000 turns `is_nearly_equal`'s 256th of a unit into tens of device units (#3327); (48) an `/ICCBased` colour space is cached by what its profile is read from (`IccProfileKey`): the stream object it names, looked up without reading the stream, and then the stream's dictionary and its own bytes (decrypted, not decoded), so a byte-for-byte copy of a profile in another object is read once, and a profile stream written directly in its array, which reports the object holding the array as every other stream written there does, is told apart by its bytes. hayro keyed it by the stream dictionary's bytes alone, so two different profiles with the same `/N` and `/Length` shared whichever was read first, and colours in the second were drawn through the first. `InterpreterCache::icc_profile_reads` counts the profiles read; (49) `OpenTypeFontBlob::new` also accepts a `post` table version 1.0, 2.0 or 3.0 written with its 16-bit halves swapped (`00 00 00 03`), as ScanSoft PDF Create! embeds every font; such fonts were refused and their text drawn with a substitute (Hebrew came out garbled or missing, #2946); `glyph_names` reads such a table from a copy with the version written correctly, so a 2.0 table's names serve `/Differences`. Any other unknown version is still refused (pdf.js 9462); (50) `CffFontBlob::glyph_index_by_name` looks a glyph name up in a name to glyph map built on first use (shared between clones) instead of searching the whole charset, with a string lookup per glyph, for every character code of a font; the first glyph with a name still wins; (51) at most 4096 graphics states saved at once, holding at most 64 MiB together (each `q` copies the whole state, dash array and clip list included, so short operators multiplied one large state); a `q` past either limit is counted but not copied, and its `Q` still removes the clips added since (from storytold/craft-libs#239); (52) a `/W2` run whose first CID is past `MAX_CID` stops there, as in `/W` (7), checked before the run's metrics are read: a first CID of 4294967295 inserted one entry and then overflowed, discarding the whole `/W2` table (from storytold/craft-libs#254); (55) a Separation or DeviceN tint transform that cannot be read keeps its colour space, and one that cannot be read or evaluated paints the alternate space's initial colour, as evaluation failures already did; that fallback, and a `cs`/`CS` colour space that cannot be read (drawn in DeviceGray as before), are now reported once per content stream as `InterpreterWarning::ColorFallback` (`RenderWarning::ColourFallback` in `pdfcraft-render`) instead of silently (#4260); (56) an annotation whose Invisible flag (bit 1) is set and whose `/Subtype` is none of the standard types of ISO 32000-2 Table 171 (`is_standard_annotation_type`; no `/Subtype` counts as unknown) is not drawn (Table 167), and a `/Popup` annotation's appearance is never painted on the page (§12.5.6.14: a popup has no appearance stream of its own; the viewer shows it as a window for its parent annotation). Both were drawn from their `/AP`; (57) a simple font drawn with a standard-font stand-in because the document names a font it doesn't embed (`StandardKind` with `fallback`) narrows a glyph to a narrower `/Widths` advance but no longer widens it to a wider one: the advance already places the next glyph (ISO 32000-2 §9.2.4; /Widths in Table 109), and widening drew a non-embedded `Arial,Bold` with 600-unit widths as a heavy fixed-pitch face whose `i` and `l` were solid blocks twice their width; an exact standard-14 font still matches its standard metrics both ways; (58) the fallback request for a Type 0 font that is not embedded (`FallbackFontQuery::with_descriptor`) takes its serif, italic and small-cap flags, `/FontWeight`, `/FontFamily` and `/FontStretch` from the descendant CIDFont's font descriptor (ISO 32000-2 §9.7.4.1, §9.7.6.1; §9.8): it read them from the Type 0 dictionary, which has none, so every such request asked for a regular sans-serif face and no resolver could tell a Song or Mincho font from a Hei or Gothic one, or see `/FontWeight 700`; (59) the NoRotate flag (`/F` bit 5) is honoured: the appearance, fitted to `/Rect`, is turned against the page's `/Rotate` about the rectangle's upper-left corner, which stays where it is, so it reads upright as the page is displayed (ISO 32000-2 §12.5.3). Sticky notes, which are saved NoRotate, were drawn turned with the page (#2583). Form fields and links stay in `/Rect`, where PdfCraft takes their click targets from it; (64) text in a Type 3 font follows ISO 32000-2 §9.3.6 in the clipping modes: under `7 Tr` it is not rendered (reported to the device as clipping text, `GlyphDrawMode::Clip`, so extraction keeps it), and in modes 4 to 7 a Type 3 glyph adds nothing to the clipping path (`clip_glyph`), so text shown only in a Type 3 font leaves no clip at `ET`. Since (42) `7 Tr` painted the glyphs in the fill colour (black by default), so a fill meant to follow showed them wherever it didn't cover them; 4 to 6 still render filled; (66) a Separation colour space whose colourant is the special name `All` paints every colourant at once (ISO 32000-2 §8.6.6.4): on screen and in an RGB print raster its tint t is the gray 1 − t, complemented because the device is additive, whatever its alternate space and tint transform say (hayro drew it through the tint transform, so a `/DeviceCMYK` transform giving cyan drew registration marks light cyan); `/None` still paints nothing, any other name still goes through its alternate, and a DeviceN space naming `All` keeps its alternate, since the clause gives the name that meaning in a Separation space; (63) a luminosity soft mask whose group colour space is `/DeviceCMYK` (by name, not an ICC-based or calibrated space) reads a `/DeviceCMYK` fill or stroke colour drawn in it, and its `/BC`, as their gray `1 - min(1, 0.3 c + 0.59 m + 0.11 y + k)` (ISO 32000-2 §10.4.2.3, §11.5.3: device colour converts to gray with no colour calibration; `Context::luminosity_in_device_cmyk`, `color::device_cmyk_to_gray`): it went through the CMYK display profile, where 100% K is a dark grey, so a 100% K mask let about an eighth of the masked paint through. CMYK images, shadings and tiling patterns inside such a mask, and CMYK on the page, still use the profile; (71) a lookup table in an indirect string reads: it was read as a stream first, which uses up a reference (ISO 32000-2 §8.6.6.3); (72) an uncoloured pattern's underlying space given as a name or a reference is used: it was skipped, so a CMYK cyan drew red (ISO 32000-2 §8.7.3.3); (73) `InterpreterSettings::jbig2_work`, set for each page in `interpret_page` and ended with it, and a JBIG2 image or an image with a JBIG2 mask past it left out with `ContentTruncated`; (74) colour spaces nest at most 32 levels, through every way one names another, so a chain of them ends instead of overflowing the stack; an ICC profile's `/Alternate` is read where it's met and isn't kept with the profile, or a page could build a chain past the limit on spaces already read; (75) a soft hyphen (U+00AD) that a CFF substitute for a CID font has no glyph for draws its hyphen, as Annex D.2 reads WinAnsi 0xAD in (30), instead of the CID taken as a glyph index (#5888) | (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) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`, `raster::tests::comma_below_letters_in_standard_fonts_draw_a_comma_under_the_letter`; (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`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (30) `text::tests::the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space`; (31) `raster::tests::shadings_with_large_sampled_functions_paint_quickly`, `raster::tests::sampled_functions_interpolate_their_grid_as_before`; (32) `raster::tests::decoded_images_serve_every_size_they_are_drawn_at`; (33) `raster::tests::patch_meshes_keep_their_corner_colours`; (34) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`; (35) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (36) `raster::tests::mesh_triangles_sharing_an_edge_leave_no_seam`; (38) `raster::tests::default_colour_spaces::device_rgb_operators_draw_in_default_rgb`, `raster::tests::default_colour_spaces::device_cmyk_operators_draw_in_default_cmyk`, `raster::tests::default_colour_spaces::device_gray_operators_draw_in_default_gray`, `raster::tests::default_colour_spaces::device_images_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::device_shadings_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::an_image_drawn_with_and_without_a_default_keeps_both_colours`, `raster::tests::default_colour_spaces::forms_inherit_the_default_colour_spaces_they_do_not_override`, `raster::tests::default_colour_spaces::unusable_defaults_leave_device_colour_alone`; (39) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (40) `raster::tests::visibility_expressions_decide_what_a_membership_dictionary_shows`; (41) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (42) `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`; (43) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours`; (44) `text::tests::identity_to_unicode_names_give_text`; (46) `raster::tests::stencil_masks_are_drawn_at_the_fill_opacity_once`; (47) `raster::tests::a_five_point_polyline_is_not_painted_as_its_bounding_box`, `raster::tests::a_path_that_visits_every_corner_out_of_order_is_not_a_rectangle`, `raster::tests::a_rectangle_is_still_painted_as_a_rectangle`; (48) `raster::tests::icc_profile_cache::icc_profiles_with_identical_dictionaries_draw_their_own_colours`, `raster::tests::icc_profile_cache::icc_profiles_used_again_are_read_once`; (49) `raster::tests::a_font_whose_post_version_has_swapped_halves_is_still_used`; (50) `font::blob::tests::glyph_names_resolve_to_the_first_glyph_with_that_name`; (51) `context::saved_state_tests::repeated_saves_stay_bounded_and_balanced`; (52) `font::cid::tests::w2_run_past_the_largest_cid_keeps_the_rest_of_the_table` (hayro-interpret); (55) `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_evaluated_draws_the_rest_of_the_page_and_warns`, `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_read_falls_back_and_warns`; (56) `raster::tests::invisible_unknown_annotations_and_popups_paint_nothing`; (57) `substitute_widths::a_substitute_keeps_its_glyph_shapes_at_wider_advances`, `substitute_widths::a_substitute_still_narrows_glyphs_to_narrower_advances` (pdfcraft-render `tests/it`); (58) `cid_fallback_query::a_cid_font_fallback_request_carries_its_descendants_style`, `cid_fallback_query::a_cid_font_without_a_descriptor_asks_for_the_default_style` (pdfcraft-render integration tests); (59) `raster::tests::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages` (hayro-interpret); (64) `type3_text_clip::{clip_only_type3_text_is_not_rendered_and_leaves_no_clip, invisible_type3_text_is_not_rendered, other_modes_render_type3_glyphs_filled_and_never_clip, only_outline_glyphs_shape_a_clip_shared_with_type3_text, self_nesting_type3_text_in_a_clip_mode_stays_bounded}` (`cargo test -p pdfcraft-render --test it`), `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`, `text::tests::clipping_mode_type3_text_is_kept_by_the_ocr_filter`; (66) `separation_all::{separation_all_fills_paint_the_complemented_tint_as_gray, separation_all_strokes_paint_the_complemented_tint_as_gray, separation_all_images_paint_each_sample_as_the_complemented_gray, separation_all_shadings_paint_the_complemented_gray, separation_none_paints_nothing, a_named_separation_still_paints_through_its_alternate, a_device_n_space_naming_all_still_paints_through_its_alternate}` (`cargo test -p pdfcraft-render --test it`); (63) `color::device_cmyk_gray_tests::{each_ink_darkens_by_its_iso_weight, ink_past_full_coverage_is_black_and_bad_components_read_as_zero}` (hayro-interpret), `it::luminosity_group_space::{black_ink_in_a_device_cmyk_group_masks_fully_and_no_ink_shows_fully, each_process_ink_in_a_device_cmyk_group_masks_by_its_weight, a_device_cmyk_backdrop_colour_masks_by_its_gray, gray_and_rgb_groups_mask_as_before, device_cmyk_on_the_page_keeps_its_display_colour}` (pdfcraft-render); (71) `raster::tests::an_indexed_lookup_table_is_read_in_every_legal_form` (pdfcraft-render); (72) `raster::tests::an_uncoloured_pattern_uses_its_underlying_space_in_every_form` (pdfcraft-render); (73) `raster::tests::the_jbig2_images_of_a_page_share_one_allowance`, `a_picture_whose_jbig2_mask_is_past_the_pages_allowance_is_not_drawn` and `a_pages_jbig2_allowance_ends_with_the_page` (pdfcraft-render); (74) `raster::tests::{a_chain_of_colour_spaces_ends, colour_spaces_are_read_to_the_limit_of_their_nesting, a_chain_built_on_spaces_already_read_ends, a_profile_read_at_the_limit_keeps_its_alternate_for_a_read_nearer_the_top}` (pdfcraft-render); (75) `raster::tests::a_soft_hyphen_a_substitute_lacks_draws_its_hyphen` (pdfcraft-render) | +| 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 image whose axes lie along the device's (upright, mirrored or a quarter turn, as on a page under `/Rotate`) is snapped to whole device pixels: shrunk once to its rounded device rectangle and drawn 1:1 from 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, and until #5530 a turned one still did, up to 200 grey levels off the same page drawn upright). An image counts as axis-aligned when the terms the snap drops move its far edges by at most a hundredth of a device pixel (`SNAP_SKEW`), not by a tolerance on the coefficients, which let a slight skew of a very wide image through; and the snapped 1:1 transform is used only when the shrink produced the snapped size, so a failed resize keeps the full transform instead of drawing the source raster at its source size; (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; (17) the renderer keeps annotation paints out of the reading-colours mapping between `begin_annotations` and `end_annotations` (#1548); (18) the page renders as one transparency group; under reading colours its background is painted inside it as the initial backdrop, so a Multiply highlight blends with the dark background instead of staying an opaque block, and otherwise the background stays outside, so a blend mode over an empty part of the page keeps its colour instead of blending with the white paper (#2970); (19) `set_paint` samples a mesh shading's texture over its triangles alone (`EncodedShadingPattern::extent`, padded rather than repeated) and skips a mesh with none on the canvas: a BricsCAD plan's 310 one-triangle `sh` each covered the whole tile, 2 s per 1024 px tile at any zoom (the watchdog gave the page up on a busy machine), now about 70 ms; (20) a tiling pattern whose step is too large for a cell pixmap, where what is painted (a fill, or a stroke with its width) can only show the first cell (PowerPoint's `/XStep 2147480100` for a picture fill drawn once), has its `/BBox` drawn at full scale with a transparent border and padded instead of tiled (`shows_one_cell`; a cell whose scale isn't finite and positive, from a `/BBox` too thin to divide by, keeps the old path: `usable_cell_scale`); shrinking the cell to fit the 3000 px limit of (3) left the picture under a pixel, so network diagrams lost their pictures (#3349); (22) knockout groups: while one is innermost, fills, strokes, glyph outlines and shadings are drawn with `Compose::Copy` (weighted by coverage), so each element replaces what earlier elements of the group drew instead of compositing over it; an image or nested group, drawn into a layer of its own, first has what is under its shape cleared (`knockout_image` for an unmasked image's rectangle, only when `draw_image` will draw it, decoded at the size it is drawn; a knockout erase pass that draws a nested group's content with every paint clearing instead of painting, a shading fill's clear clipped to the shading's `/BBox` as its paint is) and then composites over it (#2971); (23) 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; (25) a tiling cell is drawn at the scale that makes one `/XStep` (`/YStep`) exactly the cell pixmap's whole number of pixels (`tiling_cell_pixels`, at least 1 and at most the cap), so the repeated pixmap puts cells at whole multiples of the step at any device scale; drawing at the unrounded scale repeated the cell up to half a pixel off at every step, and the error grew with distance from the pattern's origin (ISO 32000-2 8.7.3.1) | (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`), `raster::tests::a_turned_or_mirrored_minified_image_snaps_to_whole_pixels`, `rotated_image_snap::a_shrunk_image_on_a_turned_page_is_the_upright_render_turned` (`cargo test -p pdfcraft-render --test it`), `renderer::snap_tests::a_skew_that_moves_a_far_edge_is_not_snapped` (`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`; (17), (18) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (19) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (20) `raster::tests::a_tiling_pattern_drawn_once_shows_its_cell`, `raster::tests::a_large_step_still_tiles_where_the_next_cell_shows`, `raster::tests::a_tiling_cell_too_thin_to_scale_keeps_the_old_path`; (22) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (25) `tiling_cell_placement::{far_cells_of_a_fill_sit_at_whole_steps, far_cells_sit_at_whole_steps_under_a_turned_scaled_matrix, far_cells_of_a_stroke_sit_at_whole_steps, a_cell_pixmap_spans_exactly_one_step}` (`cargo test -p pdfcraft-render --test it`) | +| hayro-jbig2 | 0.3.0 | Apache-2.0 OR MIT | (1) at most 2^28 pixels per bitmap and page bitmap, and 2^25 cells per halftone grid and grayscale image (each side could be 65535: 4.3 billion pixels decoded one by one, and a 4-byte value per cell); (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); (4) a segment header's long-form referred-to segment count is capped at `u16::MAX` and a larger count is refused before its retention or referred-to bytes are read: the 29-bit count went straight to `Vec::with_capacity`, a reservation of about 2 GiB whose failure aborts the process (from storytold/craft-libs#176); (5) what a page's segments hold for later ones (intermediate regions, pattern and symbol dictionaries with their headers and retained contexts, and Huffman tables) is at most 64 MiB, two square bitmaps of 16,384 a side (`MAX_RETAINED_WORDS`), and a dictionary's new and exported symbols are counted against it as they are made: each was bounded on its own, but a few dozen bytes an intermediate region held 33 MB each, and 8 MiB symbols exported again and again killed the process, so a few kilobytes could exhaust memory; (6) a segment may refer to at most 2^20 symbols, checked before any are collected: references may repeat, and 65,535 of them to one dictionary, in a 78 KB stream, asked for 17 GB; (7) a pattern dictionary keeps at most 16 MiB of shifted copies, which only speed up drawing, and none that wouldn't fit what the page has left (`MAX_SHIFTED_WORDS`); (8) a custom Huffman table has at most 2^16 lines, and a stream with its globals at most 2^20 segments and 2^22 references, checked as they are read: both grew before anything counted them, and PDFs of a kilobyte or so reached 5 GB and more behind nested Flate; (9) a page past the bounds of (1) is refused when the stream is opened: a caller sizes its output from the header before the page bitmap is made, and an Indexed image in a 743-byte PDF asked for 137 GB, which aborts; (10) a stream may ask for at most 2^30 units of decoding work, four of the largest pages, about 6 s to 17 s (`MAX_WORK`): decoding a bitmap costs a pixel each and 4 a row, copying one a word each and 1 a row, and each integer an item reads costs 38, what one takes at worst, so one with no pixels pays too, before it is decoded: each bitmap was bounded, but nothing bounded how many there were, and a 795-byte PDF took 265 s; (11) `Image::decode_within` spends from what a run of images may still do, starting with its page's pixels, and `charge_reading` charges reading a stream and its globals before anything is parsed, so an operation over a document can bound what `MAX_WORK` bounds for one image, and images that fail or decode nothing add up too: a file of thousands of images made to reach that limit took as many times as long; (12) adding to a location, a reference offset, a symbol's corner or a Huffman range line near the end of an `i32`, and negating a decoded integer of 2^31, overflowed: a panic wherever overflow is checked. The refinement context, a text region's corners, the integer decoder and the Huffman table wrap, as a release build did, and `Bitmap::combine` saturates, which comes to the same, as a location that overflows is past the end of any page. What is drawn doesn't change | (1) `raster::tests::jbig2_regions_of_billions_of_pixels_are_refused`, `raster::tests::jbig2_halftone_grids_past_their_bound_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`; (4) `segment::tests::test_referred_to_count_is_capped` (`cargo test --manifest-path vendor/hayro-jbig2/Cargo.toml`); (5) `raster::tests::jbig2_regions_held_past_a_pages_bound_are_refused`, `jbig2_symbols_without_pixels_count_towards_a_pages_bound`, `jbig2_retained_contexts_count_towards_a_pages_bound`, `jbig2_huffman_tables_count_towards_a_pages_bound`, `jbig2_new_symbols_count_towards_a_pages_bound`, `jbig2_new_symbols_are_held_to_what_a_page_has_left`, `jbig2_pattern_dictionaries_count_towards_a_pages_bound`, and in the crate (`cargo test --manifest-path vendor/hayro-jbig2/Cargo.toml`) `decode::symbol::tests::exported_symbols_are_counted_before_they_are_copied`, `tests::the_room_left_shrinks_as_segments_are_stored`; (6) `raster::tests::jbig2_segments_referring_to_too_many_symbols_are_refused`; (7) `raster::tests::jbig2_pattern_dictionaries_too_large_to_shift_still_draw`, `jbig2_shifted_patterns_never_refuse_an_image`; (8) `raster::tests::jbig2_huffman_tables_of_too_many_lines_are_refused`, `jbig2_streams_of_too_many_segments_are_refused`, `jbig2_streams_of_too_many_references_are_refused`, and in the crate `tests::the_segments_of_a_stream_and_its_globals_count_together`; (9) `raster::tests::jbig2_pages_past_the_bounds_are_refused_before_anything_is_sized_from_them`, `tests::a_page_past_the_bounds_is_refused_when_opened`; (10) `raster::tests::jbig2_decoding_work_is_counted_item_by_item`; (11) `jbig2::decode::tests::a_run_of_images_spends_its_allowance_down_to_the_step` (pdfcraft-filters); (12) `raster::tests::jbig2_placements_at_the_end_of_an_i32_draw_nothing` | | 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; (6) `find_latest_trailer`: a rebuild takes a `/Type /XRef` stream dictionary as the trailer when the file has no `trailer` keyword (the latest in the file wins, as a later `trailer` would), so a concatenated or truncated file keeps its /Info and /ID instead of failing to load; a candidate whose /Root is the catalog beats one whose /Root is another dictionary, and an in-file /Root must match its generation (#2365) | (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`; (6) `inspect::tests::rebuilt_file_keeps_the_info_of_its_cross_reference_stream`, `inspect::tests::rebuilt_file_prefers_a_trailer_whose_root_is_the_catalog` | -| 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); an EI followed by a delimiter (EI%..., EI[...) ends the image like one followed by white space; only a regular character after EI skips the candidate; (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.; (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; (18) The streaming Flate decoder (`lzw_flate::flate::fallback`) copies a back-reference in runs of up to its distance (`extend_from_within`), not byte by byte. The copy reads only bytes already in the output, and a copy whose source does not exist still writes nothing, so the bytes are those a byte-by-byte copy writes. Inflating the A1 sample's content stream (278.6 MB) takes about 14% less time (0.66 s to 0.56 s); the output is identical; (19) CCITT `/Rows` below the image's `/Height` decodes to the height: a plotter driver wrote `/Rows` equal to `/Columns`, so only the first rows were decoded and the rest drawn black (#3308) (20) `stream` followed by spaces or tabs and then the line break (`stream \r\n`, as Microsoft Reporting Services writes it) starts the data after the line break; the content stream was taken to start at the space and the page rendered blank.; (21) a JP2 colour specification naming an enumerated colour space the decoder refuses (CIEJab in a veraPDF fixture, YCbCr, PhotoYCC, YPbPr, bilevel…) no longer fails the image: it is decoded again from a copy whose `colr` names the space the decoder assumes without one, greyscale, sRGB or CMYK by component count, as MuPDF and Poppler draw it (`without_refused_enumerated_colour`; the page drew blank, #3306); (22) an object stream's index of `objnum offset` pairs is parsed once per stream and kept beside its decoded data (`ObjectStreamIndexes`, `XRef::with_object_stream_indexes`), and an offset past `usize` is malformed instead of wrapping: `XRef::get_with` parsed the whole index again for every object it fetched from the stream, so reading all of a stream of N objects took N² steps (a Firefox page whose 65,542 objects share one stream took 37 s to draw and 40 s to extract its text); (23) the inline-image end-of-data look-ahead no longer rejects a candidate `EI` because a byte above 0x7F lies between it and the next one: when the candidate is the end, those bytes are the content stream that follows the image, and its strings carry such bytes (WinAnsi `(Grüße) Tj`, the two-byte codes of any Type 0 font), so the operator check that follows decides alone, bounded as in (4) (a scanned page whose OCR text held an accented word and a later capital "EI" — EINGANG, BEI, KLEIN — drew and extracted nothing after the image: 40 of 275 pages across five real documents); (24) `xref::prev_offset`: a trailer `/Prev` written as a reference `N G R` is read as the offset that object holds when the section's own entries place it (iText's `indirectPrev.pdf`; each section's entries are read before its `Prev` is followed), and otherwise as offset N (tiff2pdf; JHOVE's PDF-HUL-157 files, #2366), as `pdfcraft-cos` and other readers take it, so the earlier sections' pages are seen instead of one; (25) 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; (26) an xref stream subsection must be backed by its decoded data: all-zero `/W` widths (an entry reads no bytes) and a declared count longer than the data are refused before any entry is inserted, where a tiny stream with a large `/Size` or `/Index` count inserted that many entries (from storytold/craft-libs#239); a subsection running past object number `i32::MAX` is refused instead of overflowing or turning negative; (27) an xref stream's type field is read big-endian at its full `/W[0]` width, where only its first byte was read and wider fields shifted every later entry, and `/W[0]` over 8 bytes is refused like `/W[1]` (from storytold/craft-libs#241); (28) a content instruction keeps up to 256 operands (`MAX_OPERANDS`) instead of 10, and past that drops the oldest instead of ending the content stream: the eleventh operand of a twelve-ink DeviceN `scn` (ISO 32000-2 §8.6.6.5 allows any number of components) blanked the rest of the page (#4260) | (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`, `content::tests::inline_image_ei_may_be_followed_by_a_delimiter`; (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`; (18) `filter::lzw_flate::tests::back_reference_longer_than_its_distance_repeats_its_source`, `back_reference_cut_by_the_limit_resumes_in_the_next_call` and `back_reference_reads_bytes_written_by_an_earlier_call`; each fails when the copy returns the wrong remaining count; (19) `raster::tests::group4_fax_with_rows_below_its_height_decodes_to_the_image_height`; (20) `raster::tests::space_before_the_line_break_after_stream_still_renders`; (21) `raster::tests::a_jp2_image_in_a_refused_enumerated_colour_space_is_drawn`, `filter::jpx::tests::refused_enumerated_colour_spaces_become_the_component_counts_default`, `filter::jpx::tests::what_the_decoder_handles_or_cant_be_read_is_left_alone` (`cargo test --manifest-path vendor/hayro-syntax/Cargo.toml`); (22) `raster::tests::objects_in_one_large_object_stream_are_read_in_linear_time`; (23) `text::tests::text_after_an_inline_image_survives_a_non_ascii_byte_before_a_later_ei`, `content::tests::an_inline_image_ends_at_its_own_ei_when_later_text_holds_a_non_ascii_byte` (hayro-syntax); (24) `xref::tests::prev_as_reference_reaches_the_earlier_section`, `xref::tests::prev_as_reference_to_an_object_holding_the_offset_reads_that_offset`, `pdfcraft-cos` `document::tests::a_prev_written_as_a_reference_still_reaches_the_earlier_sections`, `document::tests::a_prev_reference_to_an_object_holding_the_offset_reads_that_offset`; (25) `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`; (26) `xref::tests::xref_stream_entries_need_backing_bytes`; (27) `xref::tests::xref_stream_type_field_uses_the_full_w0_width`, `xref::tests::xref_stream_loader_rejects_oversized_w0` (`cargo test --manifest-path vendor/hayro-syntax/Cargo.toml`); (28) `content::ops::tests::scn_with_twelve_components_keeps_them_all`, `content::ops::tests::operands_past_the_cap_keep_the_latest_in_order` (hayro-syntax), `raster::tests::devicen_inks::a_twelve_ink_devicen_colour_paints`, `raster::tests::devicen_inks::a_four_ink_devicen_colour_paints`, `raster::tests::devicen_inks::an_operator_with_hundreds_of_operands_does_not_end_the_content` | +| 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); an EI followed by a delimiter (EI%..., EI[...) ends the image like one followed by white space; only a regular character after EI skips the candidate; (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.; (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; (18) The streaming Flate decoder (`lzw_flate::flate::fallback`) copies a back-reference in runs of up to its distance (`extend_from_within`), not byte by byte. The copy reads only bytes already in the output, and a copy whose source does not exist still writes nothing, so the bytes are those a byte-by-byte copy writes. Inflating the A1 sample's content stream (278.6 MB) takes about 14% less time (0.66 s to 0.56 s); the output is identical; (19) CCITT `/Rows` below the image's `/Height` decodes to the height: a plotter driver wrote `/Rows` equal to `/Columns`, so only the first rows were decoded and the rest drawn black (#3308) (20) `stream` followed by spaces or tabs and then the line break (`stream \r\n`, as Microsoft Reporting Services writes it) starts the data after the line break; the content stream was taken to start at the space and the page rendered blank.; (21) a JP2 colour specification naming an enumerated colour space the decoder refuses (CIEJab in a veraPDF fixture, YCbCr, PhotoYCC, YPbPr, bilevel…) no longer fails the image: it is decoded again from a copy whose `colr` names the space the decoder assumes without one, greyscale, sRGB or CMYK by component count, as MuPDF and Poppler draw it (`without_refused_enumerated_colour`; the page drew blank, #3306); (22) an object stream's index of `objnum offset` pairs is parsed once per stream and kept beside its decoded data (`ObjectStreamIndexes`, `XRef::with_object_stream_indexes`), and an offset past `usize` is malformed instead of wrapping: `XRef::get_with` parsed the whole index again for every object it fetched from the stream, so reading all of a stream of N objects took N² steps (a Firefox page whose 65,542 objects share one stream took 37 s to draw and 40 s to extract its text); (23) the inline-image end-of-data look-ahead no longer rejects a candidate `EI` because a byte above 0x7F lies between it and the next one: when the candidate is the end, those bytes are the content stream that follows the image, and its strings carry such bytes (WinAnsi `(Grüße) Tj`, the two-byte codes of any Type 0 font), so the operator check that follows decides alone, bounded as in (4) (a scanned page whose OCR text held an accented word and a later capital "EI" — EINGANG, BEI, KLEIN — drew and extracted nothing after the image: 40 of 275 pages across five real documents); (24) `xref::prev_offset`: a trailer `/Prev` written as a reference `N G R` is read as the offset that object holds when the section's own entries place it (iText's `indirectPrev.pdf`; each section's entries are read before its `Prev` is followed), and otherwise as offset N (tiff2pdf; JHOVE's PDF-HUL-157 files, #2366), as `pdfcraft-cos` and other readers take it, so the earlier sections' pages are seen instead of one; (25) 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; (26) an xref stream subsection must be backed by its decoded data: all-zero `/W` widths (an entry reads no bytes) and a declared count longer than the data are refused before any entry is inserted, where a tiny stream with a large `/Size` or `/Index` count inserted that many entries (from storytold/craft-libs#239); a subsection running past object number `i32::MAX` is refused instead of overflowing or turning negative; (27) an xref stream's type field is read big-endian at its full `/W[0]` width, where only its first byte was read and wider fields shifted every later entry, and `/W[0]` over 8 bytes is refused like `/W[1]` (from storytold/craft-libs#241); (28) a content instruction keeps up to 256 operands (`MAX_OPERANDS`) instead of 10, and past that drops the oldest instead of ending the content stream: the eleventh operand of a twelve-ink DeviceN `scn` (ISO 32000-2 §8.6.6.5 allows any number of components) blanked the rest of the page (#4260); (29) a `/JBIG2Globals` stream that is itself JBIG2 isn't decoded: globals are raw segments, and a chain of 16,000 such streams overflowed the stack, which aborts the process; (31) `set_page_jbig2_work`: the JBIG2 images decoded on a thread spend one allowance, set per page, 2^30 steps (`MAX_PAGE_JBIG2_WORK`), so a page of many images made to reach their own limit stops at one, and what they cost to read counts, whether they decode or not; unbounded until a page sets it; (32) a group of array items read together (a tuple: an image's `/Decode`, `/W`, `/W2`, and a function's `/Domain`, `/Range`, `/Encode` and `/Decode`) can start with a reference: it was taken for the whole group and lost, which ended the array, so an image with `/Decode [7 0 R 0]` didn't draw and CID widths fell back to `/DW`; (33) an object that holds only a reference stands for what that names, stored on its own or in an object stream, up to 32 references: read as a number it was the reference's own object number, so a `/Decode` or `/W` entry naming such an object took a wrong value, and as anything else it was lost. Past 32, as any cycle goes, it reads as a missing object. An object stream isn't reached that way, as its checks are of the object named; reading its index counts against the 64 nested loads and reads its dictionary alone, as a chain of them, each with a member of the next for its `/N`, `/First` or `/Length`, ran out of stack; and a comment inside a reference counts as white space; (34) a table rebuilt by scanning the file (`fallback_xref_map`) finds an object by its number when its header names another generation, including one no generation may be (`3 18446744073709551616 obj` in `poppler-85140-0.pdf`, #5580): the header is read leniently only in that scan (`read_object_header`), the object is also filed under generation 0 unless a real `N 0 obj` holds that key (a later header for the number moves the stand-in), `XRef::get_with` matches by object number only in a repaired table, and `XRef::objects` yields each location once | (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`, `content::tests::inline_image_ei_may_be_followed_by_a_delimiter`; (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`; (18) `filter::lzw_flate::tests::back_reference_longer_than_its_distance_repeats_its_source`, `back_reference_cut_by_the_limit_resumes_in_the_next_call` and `back_reference_reads_bytes_written_by_an_earlier_call`; each fails when the copy returns the wrong remaining count; (19) `raster::tests::group4_fax_with_rows_below_its_height_decodes_to_the_image_height`; (20) `raster::tests::space_before_the_line_break_after_stream_still_renders`; (21) `raster::tests::a_jp2_image_in_a_refused_enumerated_colour_space_is_drawn`, `filter::jpx::tests::refused_enumerated_colour_spaces_become_the_component_counts_default`, `filter::jpx::tests::what_the_decoder_handles_or_cant_be_read_is_left_alone` (`cargo test --manifest-path vendor/hayro-syntax/Cargo.toml`); (22) `raster::tests::objects_in_one_large_object_stream_are_read_in_linear_time`; (23) `text::tests::text_after_an_inline_image_survives_a_non_ascii_byte_before_a_later_ei`, `content::tests::an_inline_image_ends_at_its_own_ei_when_later_text_holds_a_non_ascii_byte` (hayro-syntax); (24) `xref::tests::prev_as_reference_reaches_the_earlier_section`, `xref::tests::prev_as_reference_to_an_object_holding_the_offset_reads_that_offset`, `pdfcraft-cos` `document::tests::a_prev_written_as_a_reference_still_reaches_the_earlier_sections`, `document::tests::a_prev_reference_to_an_object_holding_the_offset_reads_that_offset`; (25) `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`; (26) `xref::tests::xref_stream_entries_need_backing_bytes`; (27) `xref::tests::xref_stream_type_field_uses_the_full_w0_width`, `xref::tests::xref_stream_loader_rejects_oversized_w0` (`cargo test --manifest-path vendor/hayro-syntax/Cargo.toml`); (28) `content::ops::tests::scn_with_twelve_components_keeps_them_all`, `content::ops::tests::operands_past_the_cap_keep_the_latest_in_order` (hayro-syntax), `raster::tests::devicen_inks::a_twelve_ink_devicen_colour_paints`, `raster::tests::devicen_inks::a_four_ink_devicen_colour_paints`, `raster::tests::devicen_inks::an_operator_with_hundreds_of_operands_does_not_end_the_content`; (29) `raster::tests::jbig2_globals_that_are_jbig2_themselves_are_not_decoded`, `raster::tests::jbig2_globals_are_read`, `raster::tests::the_jbig2_images_of_a_page_share_one_allowance`, `raster::tests::an_image_that_fails_spends_the_pages_allowance_too`; (32) `raster::tests::a_decode_array_with_references_in_it_is_read`, `raster::tests::a_functions_arrays_can_start_with_a_reference` (pdfcraft-render), `font::cid::width_tests::a_width_group_can_start_with_a_reference` (hayro-interpret); (33) `raster::tests::a_reference_to_a_reference_is_what_it_names`, `raster::tests::a_decode_array_with_references_in_it_is_read` (pdfcraft-render), `font::cid::width_tests::a_width_group_can_start_with_a_reference` (hayro-interpret), `xref::tests::an_object_stream_isnt_reached_through_a_reference`, `xref::tests::a_chain_of_object_streams_ends_at_the_limit_of_nested_loads`, `xref::tests::a_chain_of_object_streams_takes_no_more_work_for_two_ways_on`, `xref::tests::a_reference_held_in_either_storage_is_followed`, `xref::tests::a_page_a_reference_is_held_to_is_found_once` (hayro-syntax); (34) `raster::tests::a_rebuilt_table_finds_an_object_whose_header_names_another_generation`, `raster::tests::a_rebuilt_tables_generation_0_stand_in_is_not_a_second_object` (pdfcraft-render), `xref::tests::an_object_stream_and_its_first_member_are_both_enumerated` (hayro-syntax) | | 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; (13) on Wayland, pixel-only wheel, wheel-tilt and continuous-source scroll events report `Moved` rather than starting an unterminated finger gesture, so egui follows modifier release instead of keeping Ctrl zoom latched (#991). Finger gestures and unknown sources retain their existing phase policy | (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; (13) `pdfcraft` `tests/winit_wayland_scroll.rs` compiles the production phase policy and exercises modifier release through egui; live COSMIC confirmation remains open | | 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::vendor_provenance_egui_wgpu_surface_fit_identity`, 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::vendor_provenance_egui_wgpu_catch_errors_identity`, 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 | @@ -131,3 +131,14 @@ ones, as ISO 32000-2 §9.6.5.4 prescribes for symbolic fonts; the order the font lists them, platform ID first, so a wrong (1, 0) table (SAS 9.4's AMT fonts) drew "8" as "p" and "+" as "V". Covered by `raster::tests::symbolic_truetype_cmap`, whose font is built in memory. + +## KenM76 integration patches + +| PR | Vendored change | Regression coverage | +|---|---|---| +| #4841 | Ignore unmatched Q operators at each content-stream floor. | `stray_q_in_a_form`, `redact_after_stray_q_form` | +| #4898 | Preserve non-isolated knockout-group backdrops across blend-mode passes, masks, and shading. | `knockout_backdrop` | +| #4913 | Discard invalid stroke outlines without losing valid path segments. | `stroke_degenerate_cubic` | +| #5178 | Read OpenType-wrapped CFF programs for embedded simple fonts. | `open_type_cff_simple_font` | +| #5199 | Preserve gray under nonseparable Color blending in vello_cpu. | `nonseparable_gray` | +| #5228 | Blend supported isolated DeviceCMYK groups in their ink space. | `raster::tests::isolated_cmyk_groups` | diff --git a/vendor/hayro-interpret/src/cmyk_group.rs b/vendor/hayro-interpret/src/cmyk_group.rs new file mode 100644 index 0000000000..f791895798 --- /dev/null +++ b/vendor/hayro-interpret/src/cmyk_group.rs @@ -0,0 +1,100 @@ +//! PdfCraft patch (70): an isolated transparency group that blends in `DeviceCMYK`. +//! +//! ISO 32000-2 §11.3.4: a group's elements blend in the group's colour space, and the +//! components of a subtractive space are complemented before the blend function and +//! complemented back after it. The renderer composites in RGB, which gives other colours +//! wherever a blend mode meets CMYK artwork: `0 1 0 0 k` (magenta) under `0 0 0 1 k` (black) +//! with `/BM /Difference` is green when blended in CMYK (`|(1,0,1,1) − (1,1,1,0)|` +//! complemented back is `1 0 1 0`) and magenta when blended in RGB. +//! +//! An *isolated* group starts from a transparent backdrop of its own (§11.4.7), so its +//! interior can be computed apart from the page and handed back as finished pixels. A device +//! that can blend in CMYK takes such a group through [`Device::draw_isolated_cmyk_group`] and +//! interprets it with [`CmykGroup::interpret`] as often as it needs; every other device, and a +//! device that declines, gets the group's contents as before. + +use crate::InterpreterSettings; +use crate::context::{Context, InterpreterCache}; +use crate::device::Device; +use crate::interpret::state::{ClipType, State}; +use crate::x_object::{FormXObject, interpret_form_contents}; +use hayro_syntax::object::dict::keys::{CS, GROUP}; +use hayro_syntax::object::{Dict, Name}; +use hayro_syntax::page::Resources; +use hayro_syntax::xref::XRef; +use kurbo::{Affine, Rect, Shape}; +use std::ops::Deref; + +/// An isolated, non-knockout transparency group whose blending colour space is `DeviceCMYK`, +/// captured where it is painted (PdfCraft patch (70); see the module documentation). +pub struct CmykGroup<'a, 'b> { + form: &'b FormXObject<'a>, + resources: &'b Resources<'a>, + /// The graphics state the group's contents start with: the state at `Do`, after the + /// form's `/Matrix`, with the group's own opacity, soft mask and blend mode already taken + /// by the layer the group is composited through. + state: State<'a>, + root_transform: Affine, + bbox: Rect, + interpreter_cache: InterpreterCache<'a>, + settings: InterpreterSettings, + xref: &'a XRef, + nesting_depth: u32, +} + +impl<'a, 'b> CmykGroup<'a, 'b> { + pub(crate) fn new(form: &'b FormXObject<'a>, resources: &'b Resources<'a>, context: &Context<'a>) -> Self { + let mut state = context.get().clone(); + // The clips in force belong to the device the group is composited onto; contents + // interpreted on their own push and pop only the clips they set themselves. + state.clips = Vec::::new(); + Self { + form, + resources, + state, + root_transform: context.root_transform(), + bbox: context.bbox(), + interpreter_cache: context.interpreter_cache.clone(), + settings: context.settings.clone(), + xref: context.xref, + nesting_depth: context.nesting_depth(), + } + } + + /// The device-space box the group can paint: its `/BBox` under the current transform, + /// within the area being drawn. + pub fn device_bbox(&self) -> Rect { + let [x0, y0, x1, y1] = self.form.bbox; + let user = Rect::new(x0 as f64, y0 as f64, x1 as f64, y1 as f64); + let device = (self.state.ctm * user.to_path(0.1)).bounding_box(); + device.intersect(self.bbox) + } + + /// Interpret the group's contents into `device`, with device space moved by `offset` + /// (so a device covering only [`Self::device_bbox`] can start at its corner). + pub fn interpret(&self, device: &mut impl Device<'a>, offset: (f64, f64)) { + let shift = Affine::translate((-offset.0, -offset.1)); + let mut state = self.state.clone(); + state.ctm = shift * state.ctm; + let mut context = Context::new_with( + shift * self.root_transform, + shift.transform_rect_bbox(self.bbox), + &self.interpreter_cache, + self.xref, + self.settings.clone(), + state, + self.nesting_depth, + ); + interpret_form_contents(self.resources, self.form, &mut context, device); + } +} + +/// Whether `form`'s transparency group blends in `DeviceCMYK`: it says so in its `/CS`. +pub(crate) fn blends_in_device_cmyk(form: &Dict<'_>) -> bool { + form.get::>(GROUP).and_then(|g| g.get::>(CS)).is_some_and(|cs| matches!(cs.deref(), b"DeviceCMYK" | b"CMYK")) +} + +/// Whether `form`'s transparency group names a blending colour space of its own (`/CS`). +pub(crate) fn names_a_blending_space(form: &Dict<'_>) -> bool { + form.get::>(GROUP).is_some_and(|g| g.contains_key(CS)) +} diff --git a/vendor/hayro-interpret/src/color.rs b/vendor/hayro-interpret/src/color.rs index 531e3b01c9..b93b69c73c 100644 --- a/vendor/hayro-interpret/src/color.rs +++ b/vendor/hayro-interpret/src/color.rs @@ -166,12 +166,24 @@ fn icc_stream_object(array: &Array<'_>, stream: &Stream<'_>) -> Option, cache: &Cache) -> Option { - Self::new_inner(object, cache) + fn new(object: Object<'_>, cache: &Cache, depth: u32) -> Option { + if depth >= MAX_COLOR_SPACE_DEPTH { + warn!("colour spaces nest past the limit of {MAX_COLOR_SPACE_DEPTH} levels"); + return None; + } + + Self::new_inner(object, cache, depth) } - fn new_inner(object: Object<'_>, cache: &Cache) -> Option { + fn new_inner(object: Object<'_>, cache: &Cache, depth: u32) -> Option { if let Object::Name(name) = object { return Self::new_from_name(&name); } else if let Object::Array(color_array) = object { @@ -186,6 +198,9 @@ impl ColorSpaceType { // PdfCraft patch (48): cached by what the profile is read from (see // `IccProfileKey`), not by the stream dictionary's bytes alone. + // PdfCraft patch (74): what's kept is what the profile itself reads as. What + // it falls back to depends on how deep it was met, and kept with the profile + // a page could build a chain past the limit on spaces already read. let read = || { let data = icc_stream.raw_data(); let content = IccProfileKey::Content { @@ -194,40 +209,37 @@ impl ColorSpaceType { }; cache.get_or_insert_with(hash128(&content), || { cache.count_icc_profile_read(); - if let Some(decoded) = icc_stream.decoded().ok().as_ref() { - ICCProfile::new(decoded, num_components) - .map(|icc| { - // TODO: For SVG and PNG we can assume that the output color space is - // sRGB. If we ever implement PDF-to-PDF, we probably want to - // let the user pass the native color type and don't make this optimization - // if it's not sRGB. - if icc.is_srgb() { - Self::DeviceRgb - } else { - Self::ICCBased(icc) - } - }) - .or_else(|| { - dict.get::>(ALTERNATE) - .and_then(|o| Self::new(o, cache)) - }) - .or_else(|| match dict.get::(N) { - Some(1) => Some(Self::DeviceGray), - Some(3) => Some(Self::DeviceRgb), - Some(4) => Some(Self::DeviceCmyk), - _ => None, - }) - } else { - None - } + let decoded = icc_stream.decoded().ok()?; + Some(ICCProfile::new(&decoded, num_components).map(|icc| { + // TODO: For SVG and PNG we can assume that the output color space is + // sRGB. If we ever implement PDF-to-PDF, we probably want to + // let the user pass the native color type and don't make this optimization + // if it's not sRGB. + if icc.is_srgb() { + Self::DeviceRgb + } else { + Self::ICCBased(icc) + } + })) }) }; - return match icc_stream_object(&color_array, &icc_stream) { + let profile = match icc_stream_object(&color_array, &icc_stream) { Some(object) => { cache.get_or_insert_with(hash128(&IccProfileKey::Object(object)), read) } None => read(), }; + return profile? + .or_else(|| { + dict.get::>(ALTERNATE) + .and_then(|o| Self::new(o, cache, depth + 1)) + }) + .or_else(|| match dict.get::(N) { + Some(1) => Some(Self::DeviceGray), + Some(3) => Some(Self::DeviceRgb), + Some(4) => Some(Self::DeviceCmyk), + _ => None, + }); } CALCMYK => return Some(Self::DeviceCmyk), CALGRAY => { @@ -246,19 +258,20 @@ impl ColorSpaceType { return Some(Self::Lab(Lab::new(&lab_dict)?)); } INDEXED | I => { - return Some(Self::Indexed(Indexed::new(&color_array, cache)?)); + return Some(Self::Indexed(Indexed::new(&color_array, cache, depth)?)); } SEPARATION => { - return Some(Self::Separation(Separation::new(&color_array, cache)?)); + return Some(Self::Separation(Separation::new(&color_array, cache, depth)?)); } DEVICE_N => { - return Some(Self::DeviceN(DeviceN::new(&color_array, cache)?)); + return Some(Self::DeviceN(DeviceN::new(&color_array, cache, depth)?)); } PATTERN => { - let _ = iter.next::>(); + // PdfCraft patch (72): the family is already read. Reading a name here too took + // the underlying space when it was a name or a reference. let cs = iter .next::>() - .and_then(|o| ColorSpace::new(o, cache)) + .and_then(|o| ColorSpace::within(o, cache, depth + 1)) .unwrap_or(ColorSpace::device_rgb()); return Some(Self::Pattern(cs)); } @@ -291,7 +304,12 @@ pub struct ColorSpace(Arc); impl ColorSpace { /// Create a new color space from the given object. pub(crate) fn new(object: Object<'_>, cache: &Cache) -> Option { - Some(Self(Arc::new(ColorSpaceType::new(object, cache)?))) + Self::within(object, cache, 0) + } + + /// A colour space named by another, `depth` levels down. + fn within(object: Object<'_>, cache: &Cache, depth: u32) -> Option { + Some(Self(Arc::new(ColorSpaceType::new(object, cache, depth)?))) } /// Create a new color space from the name. @@ -991,21 +1009,22 @@ pub(crate) struct Indexed { } impl Indexed { - fn new(array: &Array<'_>, cache: &Cache) -> Option { + fn new(array: &Array<'_>, cache: &Cache, depth: u32) -> Option { let mut iter = array.flex_iter(); // Skip name let _ = iter.next::>()?; - let base_color_space = ColorSpace::new(iter.next::>()?, cache)?; + let base_color_space = ColorSpace::within(iter.next::>()?, cache, depth + 1)?; let hival = iter.next::()?.min(u8::MAX as u32) as u8; let values = { - let data = iter - .next::>() - .and_then(|s| s.decoded().ok()) - .or_else(|| { - iter.next::>() - .map(|s| Cow::Owned(s.to_vec())) - })?; + // PdfCraft patch (71): read once, as whichever it is. A failed read as a stream leaves + // a direct string in place but uses up a reference, so a table in an indirect string + // was lost, and the space with it. + let data = match iter.next::>()? { + Object::Stream(s) => s.decoded().ok()?, + Object::String(s) => Cow::Owned(s.to_vec()), + _ => return None, + }; let num_components = base_color_space.num_components(); @@ -1080,12 +1099,12 @@ fn eval_tint_transform( } impl Separation { - fn new(array: &Array<'_>, cache: &Cache) -> Option { + fn new(array: &Array<'_>, cache: &Cache, depth: u32) -> Option { let mut iter = array.flex_iter(); // Skip `/Separation` let _ = iter.next::>()?; let name = iter.next::>()?; - let alternate_space = ColorSpace::new(iter.next::>()?, cache)?; + let alternate_space = ColorSpace::within(iter.next::>()?, cache, depth + 1)?; // PdfCraft patch (55): an unreadable tint transform keeps the space (see // `eval_tint_transform`). let tint_transform = iter.next::>().and_then(|f| Function::new(&f)); @@ -1147,7 +1166,7 @@ pub(crate) struct DeviceN { } impl DeviceN { - fn new(array: &Array<'_>, cache: &Cache) -> Option { + fn new(array: &Array<'_>, cache: &Cache, depth: u32) -> Option { let mut iter = array.flex_iter(); // Skip `/DeviceN` let _ = iter.next::>()?; @@ -1158,7 +1177,7 @@ impl DeviceN { .collect::>(); let num_components = u8::try_from(names.len()).ok()?; let all_none = names.iter().all(|n| n.as_str() == "None"); - let alternate_space = ColorSpace::new(iter.next::>()?, cache)?; + let alternate_space = ColorSpace::within(iter.next::>()?, cache, depth + 1)?; // PdfCraft patch (55): an unreadable tint transform keeps the space (see // `eval_tint_transform`). let tint_transform = iter.next::>().and_then(|f| Function::new(&f)); @@ -1399,6 +1418,29 @@ impl Color { .to_rgba(&self.components, self.opacity, false) } + /// PdfCraft patch (70): the colour as process ink tints `[C, M, Y, K]` (0 = no ink) and + /// its opacity, for blending in a `DeviceCMYK` transparency group (ISO 32000-2 §11.3.4). + /// + /// `DeviceCMYK` gives its components. `DeviceGray` is black ink alone, `K = 1 − gray` + /// (§10.4.2.2). `None` for every other space, ICCBased CMYK included: such a colour has no + /// tints without a colour conversion this crate does not make (an ICCBased colour keeps + /// its own profile), and the caller draws it as before. + pub fn ink_tints(&self) -> Option<([f32; 4], f32)> { + let c = |i: usize| { + self.components + .get(i) + .copied() + .unwrap_or(0.0) + .clamp(0.0, 1.0) + }; + let tints = match self.color_space.0.as_ref() { + ColorSpaceType::DeviceCmyk => [c(0), c(1), c(2), c(3)], + ColorSpaceType::DeviceGray => [0.0, 0.0, 0.0, 1.0 - c(0)], + _ => return None, + }; + Some((tints, self.opacity.clamp(0.0, 1.0))) + } + /// Create a color from RGBA. pub fn from_rgba(rgba: AlphaColor) -> Self { let c = rgba.components(); @@ -1410,6 +1452,16 @@ impl Color { } } +/// PdfCraft patch (70): `input`, 8-bit `DeviceCMYK` pixels (four bytes each), converted to +/// 8-bit sRGB in `output` (three bytes each) through the CMYK profile pages are drawn with, +/// so a colour blended in CMYK lands where the same colour painted directly does. +pub fn cmyk_to_srgb8(input: &[u8], output: &mut [u8]) -> Option<()> { + if input.len() % 4 != 0 || output.len() != input.len() / 4 * 3 { + return None; + } + CMYK_TRANSFORM.convert_u8(input, output) +} + static CMYK_TRANSFORM: LazyLock = LazyLock::new(|| { ICCProfile::new(include_bytes!("../assets/CGATS001Compat-v2-micro.icc"), 4).unwrap() }); diff --git a/vendor/hayro-interpret/src/context.rs b/vendor/hayro-interpret/src/context.rs index ff178b25b9..de3907b1ff 100644 --- a/vendor/hayro-interpret/src/context.rs +++ b/vendor/hayro-interpret/src/context.rs @@ -173,6 +173,10 @@ pub struct Context<'a> { /// a limit was reached, the clip count to return to at its `Q`. saved_bytes: usize, skipped_saves: Vec, + /// PdfCraft patch (68): the `num_states` at which the nested content stream (a form, an + /// appearance, a glyph or pattern cell) being interpreted started, or 0 in page content. A + /// `Q` there has no `q` of its own stream to match (see `stream_floor_reached`). + stream_floor: usize, // PdfCraft patch (63). /// This is the content of a luminosity soft mask whose group colour space is `/DeviceCMYK`, /// so a `/DeviceCMYK` fill or stroke colour paints as its gray (ISO 32000-2 §11.5.3, @@ -214,6 +218,8 @@ impl<'a> Context<'a> { NESTED_PAINTS.with(|n| n.set(0)); CONTENT_LEFT.with(|c| c.set(MAX_PAGE_CONTENT)); CONTENT_TRUNCATED.with(|truncated| truncated.set(false)); + // PdfCraft patch (73): and fresh JBIG2 decoding, which every image of it shares. + hayro_syntax::set_page_jbig2_work(settings.jbig2_work); // PdfCraft patch (55): a fallback left over from another page is not this page's. crate::color::take_color_fallback(); crate::encode::reset_mesh_budget(); @@ -256,6 +262,7 @@ impl<'a> Context<'a> { nesting_depth, saved_bytes: 0, skipped_saves: Vec::new(), + stream_floor: 0, luminosity_in_device_cmyk: false, } } @@ -456,6 +463,25 @@ impl<'a> Context<'a> { self.get().graphics_state.stroke_props.clone() } + /// PdfCraft patch (68): starts a nested content stream at the current depth and returns the + /// floor of the stream it runs inside, for `leave_stream`. + pub(crate) fn enter_stream(&mut self) -> usize { + let depth = self.num_states(); + std::mem::replace(&mut self.stream_floor, depth) + } + + /// PdfCraft patch (68): ends a content stream begun with `enter_stream`. + pub(crate) fn leave_stream(&mut self, outer_floor: usize) { + self.stream_floor = outer_floor; + } + + /// PdfCraft patch (68): every `q` of the current content stream has been matched, so a `Q` + /// now would restore a state its caller saved (a stream balances its own `q` and `Q`, + /// ISO 32000-2 §8.4.2, §8.10.1). + pub(crate) fn stream_floor_reached(&self) -> bool { + self.num_states() <= self.stream_floor + } + pub(crate) fn num_states(&self) -> usize { // PdfCraft patch: saves not kept still count, so callers restore back past them. self.states.len().saturating_add(self.skipped_saves.len()) diff --git a/vendor/hayro-interpret/src/device.rs b/vendor/hayro-interpret/src/device.rs index f85969dff3..9c7953173a 100644 --- a/vendor/hayro-interpret/src/device.rs +++ b/vendor/hayro-interpret/src/device.rs @@ -1,3 +1,4 @@ +use crate::cmyk_group::CmykGroup; use crate::font::Glyph; use crate::soft_mask::SoftMask; use crate::{BlendMode, ClipPath, Image}; @@ -107,6 +108,30 @@ pub trait Device<'a> { fn begin_knockout_erase(&mut self) {} /// PdfCraft patch (39): see `knockout_erase_wanted`. fn end_knockout_erase(&mut self) {} + /// PdfCraft patch (67): the knockout group just pushed (`set_knockout`) is drawn once per + /// blend mode its elements use, each pass into a layer of its own composited with that + /// mode. In this pass only elements whose blend mode is `mode` paint; every other element + /// clears what is under its shape, as it would replace it. Each pixel then holds the last + /// element covering it in exactly one pass, which composites it with the group's backdrop + /// in that element's own blend mode (#4886). + fn begin_knockout_pass(&mut self, _mode: BlendMode) {} + /// PdfCraft patch (67): ends the pass `begin_knockout_pass` started, before the layer is + /// popped. Returns the other blend modes the pass's elements used, each once. A device + /// that doesn't track them returns none, and the group is drawn in one Normal pass. + fn end_knockout_pass(&mut self) -> Vec { + Vec::new() + } + /// PdfCraft patch (70): draw the contents of an isolated transparency group that blends + /// in `DeviceCMYK` (ISO 32000-2 §11.3.4), already inside the layer it is composited + /// through. Return `true` when the device drew them; on `false` (the default) the + /// interpreter draws the contents into the device as for any other group. + fn draw_isolated_cmyk_group(&mut self, _group: &CmykGroup<'a, '_>) -> bool { + false + } + /// PdfCraft patch (70): an isolated transparency group that names a blending colour space + /// other than `DeviceCMYK` is about to be drawn. A device drawing an enclosing group in CMYK + /// can no longer draw that group right, since this one blends in its own space. + fn group_blends_outside_device_cmyk(&mut self) {} } /// A device that discards all drawing operations. diff --git a/vendor/hayro-interpret/src/font/blob.rs b/vendor/hayro-interpret/src/font/blob.rs index 43b540cc9d..0aa8da435d 100644 --- a/vendor/hayro-interpret/src/font/blob.rs +++ b/vendor/hayro-interpret/src/font/blob.rs @@ -170,6 +170,13 @@ impl CffFontBlob { self.font().num_glyphs() } + /// PdfCraft patch (69): the `CFF ` table of an OpenType font, for a `FontFile3` of + /// subtype `OpenType` (ISO 32000-2 §9.9) under a simple font. + pub(crate) fn from_open_type(data: &[u8]) -> Option { + let cff = FontRef::new(data).ok()?.cff().ok()?; + Self::new(Arc::new(cff.offset_data().as_ref().to_vec())) + } + pub(crate) fn is_cid(&self) -> bool { self.font().is_cid() } diff --git a/vendor/hayro-interpret/src/font/cid.rs b/vendor/hayro-interpret/src/font/cid.rs index 44e076b4b1..6c4ea2bc9c 100644 --- a/vendor/hayro-interpret/src/font/cid.rs +++ b/vendor/hayro-interpret/src/font/cid.rs @@ -287,13 +287,14 @@ impl Type0Font { } FontType::Cff(c) => { // Map codepoint to glyph name via AFL, and then look it up. - if let Some(name) = glyph_names::get_reverse(character) - && let Some(gid) = c.glyph_index_by_name(name) - { - Some(gid) - } else { - None - } + let by_name = |character: char| { + glyph_names::get_reverse(character).and_then(|name| c.glyph_index_by_name(name)) + }; + // PdfCraft patch: a soft hyphen the substitute has no glyph for is its hyphen + // (#5888). Windows exporters map the hyphen glyph of a font they don't embed to + // U+00AD, which ISO 32000-2 Annex D.2 calls typographically the same as the + // hyphen; without this the CID, taken as a glyph index, drew an unrelated glyph. + by_name(character).or_else(|| (character == '\u{00ad}').then(|| by_name('-')).flatten()) } FontType::Type1(_) => { // We could in theory map to a glyph via AFL and then look up @@ -698,3 +699,75 @@ mod tests { assert_eq!(map.len(), 12); } } + +#[cfg(test)] +mod width_tests { + use super::*; + use hayro_syntax::Pdf; + use hayro_syntax::object::ObjectIdentifier; + + /// A PDF whose object 5 is `array`, for what references in it can reach. + fn with_array(array: &str) -> Pdf { + let pdf = format!( + "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 10 10] >> endobj\n\ + 4 0 obj 20 endobj\n5 0 obj {array} endobj\n6 0 obj 22 endobj\n\ + 7 0 obj 7 0 R endobj\n8 0 obj 9 0 R endobj\n9 0 obj 8 0 R endobj\n\ + 10 0 obj 4 0 R endobj\n11 0 obj [500 600] endobj\n12 0 obj [-1000 500 880] endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF\n" + ); + Pdf::new(pdf.into_bytes()).unwrap() + } + + /// `/W` and `/W2` read groups of items, and either kind can start with a reference: taken for + /// the whole group it was lost, with all that followed, and those glyphs fell back to `/DW`. + #[test] + fn a_width_group_can_start_with_a_reference() { + let widths = |array: &str| { + let pdf = with_array(array); + let array = pdf.xref().get::>(ObjectIdentifier::new(5, 0)).unwrap(); + let mut read: Vec<_> = read_widths(&array).unwrap().into_iter().collect(); + read.sort_by_key(|(cid, _)| *cid); + read + }; + let by_list = vec![(20, 500.0), (21, 600.0)]; + assert_eq!(widths("[20 [500 600]]"), by_list); + assert_eq!(widths("[4 0 R [500 600]]"), by_list); + let by_range = vec![(20, 700.0), (21, 700.0), (22, 700.0)]; + assert_eq!(widths("[20 22 700]"), by_range); + assert_eq!(widths("[4 0 R 22 700]"), by_range); + assert_eq!(widths("[4 0 R 6 0 R 700]"), by_range); + assert_eq!(widths("[1 [100] 4 0 R [500 600]]"), vec![(1, 100.0), (20, 500.0), (21, 600.0)], "after a group"); + + let vertical = |array: &str| { + let pdf = with_array(array); + let array = pdf.xref().get::>(ObjectIdentifier::new(5, 0)).unwrap(); + let mut read: Vec<_> = read_widths2(&array).unwrap().into_iter().collect(); + read.sort_by_key(|(cid, _)| *cid); + read + }; + let by_list = vec![(20, [-1000.0, 500.0, 880.0])]; + assert_eq!(vertical("[20 [-1000 500 880]]"), by_list); + assert_eq!(vertical("[4 0 R [-1000 500 880]]"), by_list); + let by_range = vec![(20, [-900.0, 400.0, 800.0]), (21, [-900.0, 400.0, 800.0]), (22, [-900.0, 400.0, 800.0])]; + assert_eq!(vertical("[20 22 -900 400 800]"), by_range); + assert_eq!(vertical("[4 0 R 22 -900 400 800]"), by_range); + + // A group's list can be given by reference too, and a reference to a reference is what it + // names. + let by_list = vec![(20, 500.0), (21, 600.0)]; + assert_eq!(widths("[20 11 0 R]"), by_list); + assert_eq!(widths("[10 0 R [500 600]]"), by_list); + assert_eq!(widths("[10 0 R 11 0 R]"), by_list); + assert_eq!(vertical("[20 12 0 R]"), vec![(20, [-1000.0, 500.0, 880.0])]); + + // A reference that only leads back to itself holds no number, so those glyphs keep `/DW`. + for array in ["[7 0 R [500 600]]", "[8 0 R [500 600]]", "[20 7 0 R 700]", "[7 0 R 8 0 R 700]", "[20 [7 0 R 600]]"] { + assert!(widths(array).is_empty(), "{array}"); + } + for array in ["[7 0 R [-1000 500 880]]", "[8 0 R 22 -900 400 800]", "[20 [7 0 R 500 880]]"] { + assert!(vertical(array).is_empty(), "{array}"); + } + } +} diff --git a/vendor/hayro-interpret/src/font/type1.rs b/vendor/hayro-interpret/src/font/type1.rs index 4ef5f953f4..cad88a5850 100644 --- a/vendor/hayro-interpret/src/font/type1.rs +++ b/vendor/hayro-interpret/src/font/type1.rs @@ -282,7 +282,13 @@ impl CffKind { fn new(dict: &Dict<'_>) -> Option { let descriptor = dict.get::>(FONT_DESC)?; let data = descriptor.get::>(FONT_FILE3)?; - let font = CffFontBlob::new(Arc::new(data.decoded().ok()?.to_vec()))?; + let bytes = data.decoded().ok()?.to_vec(); + // PdfCraft patch (69): an OpenType-wrapped program carries its CFF in a table. + let font = if bytes.starts_with(b"OTTO") { + CffFontBlob::from_open_type(&bytes)? + } else { + CffFontBlob::new(Arc::new(bytes))? + }; let (encoding, encodings) = read_encoding(dict); let (widths, missing_width) = read_widths(dict, &descriptor)?; diff --git a/vendor/hayro-interpret/src/interpret/mod.rs b/vendor/hayro-interpret/src/interpret/mod.rs index cf71eb92b5..332000e248 100644 --- a/vendor/hayro-interpret/src/interpret/mod.rs +++ b/vendor/hayro-interpret/src/interpret/mod.rs @@ -120,6 +120,10 @@ pub struct InterpreterSettings { /// costs a decode and a resample each for pixels nobody can see. Never set it for a final /// render (tiles, whole pages, print, export). pub preview: bool, + /// PdfCraft patch (73): the steps of JBIG2 decoding a page's images may take between them + /// ([`hayro_syntax::MAX_PAGE_JBIG2_WORK`] by default). Images past it aren't drawn, nor images + /// whose masks are past it, each with [`InterpreterWarning::ContentTruncated`]. + pub jbig2_work: u64, } impl Default for InterpreterSettings { @@ -142,6 +146,7 @@ impl Default for InterpreterSettings { ocg_overrides: Arc::new(Vec::new()), cancelled: None, preview: false, + jbig2_work: hayro_syntax::MAX_PAGE_JBIG2_WORK, } } } @@ -163,12 +168,25 @@ pub enum InterpreterWarning { ColorFallback, } +/// PdfCraft patch (73): ends a page's JBIG2 allowance with the page, so that what decodes on its +/// thread afterwards has no page to run out on. +struct PageJbig2Work; + +impl Drop for PageJbig2Work { + fn drop(&mut self) { + hayro_syntax::set_page_jbig2_work(u64::MAX); + } +} + /// interpret the contents of the page and render them into the device. pub fn interpret_page<'a>( page: &Page<'a>, context: &mut Context<'a>, device: &mut impl Device<'a>, ) { + // PdfCraft patch (73): a context used again is another page. + hayro_syntax::set_page_jbig2_work(context.settings.jbig2_work); + let _allowance = PageJbig2Work; let resources = page.resources(); let mut content = page.content_reader(); // PdfCraft patch: the page's own contents count against its content budget too, where they @@ -460,6 +478,12 @@ fn interpret_window<'a>( } match op { TypedInstruction::SaveState(_) => context.save_state(), + // PdfCraft patch (68): in a form or an appearance stream, a `Q` with no `q` of that + // stream to match is ignored, so it cannot pop the states of the content that drew + // it. Page content keeps its old behaviour (`stream_floor` stays 0 there). + TypedInstruction::RestoreState(_) if context.stream_floor_reached() => { + warn!("ignored a `Q` with no matching `q` in its content stream"); + } TypedInstruction::StrokeColorDeviceRgb(s) => { // PdfCraft patch (38): DeviceRGB, or the DefaultRGB that replaces it. context.get_mut().graphics_state.stroke_cs = defaults.device(DeviceFamily::Rgb); @@ -1013,12 +1037,14 @@ pub fn interpret<'a>( let num_states = context.num_states(); context.save_state(); + let outer_floor = context.enter_stream(); interpret_window(&mut ops, resources, context, device); while context.num_states() > num_states { context.restore_state(device); } + context.leave_stream(outer_floor); // PdfCraft patch (18): say that content was skipped, so a truncated page isn't a clean render. if crate::context::content_was_truncated() { (context.settings.warning_sink)(InterpreterWarning::ContentTruncated); diff --git a/vendor/hayro-interpret/src/lib.rs b/vendor/hayro-interpret/src/lib.rs index 24c3be2957..01e717da93 100644 --- a/vendor/hayro-interpret/src/lib.rs +++ b/vendor/hayro-interpret/src/lib.rs @@ -32,6 +32,7 @@ This crate has one optional feature: mod log; mod cache; +mod cmyk_group; mod context; mod convert; mod device; @@ -53,6 +54,7 @@ pub mod shading; pub mod util; pub use cache::CacheKey; +pub use cmyk_group::CmykGroup; pub use context::*; pub use device::*; pub use function::Function; diff --git a/vendor/hayro-interpret/src/ocg.rs b/vendor/hayro-interpret/src/ocg.rs index ac3e57529b..c65a195209 100644 --- a/vendor/hayro-interpret/src/ocg.rs +++ b/vendor/hayro-interpret/src/ocg.rs @@ -11,6 +11,17 @@ pub(crate) struct OcgState { } impl OcgState { + /// PdfCraft patch (67): the marked-content visibility now, to put back after content that + /// may leave it unbalanced is interpreted once more (`draw_knockout_passes`). + pub(crate) fn visibility(&self) -> Vec { + self.visibility_stack.clone() + } + + /// PdfCraft patch (67): see `visibility`. + pub(crate) fn set_visibility(&mut self, visibility: Vec) { + self.visibility_stack = visibility; + } + fn dummy() -> Self { Self { inactive_ocgs: HashSet::default(), diff --git a/vendor/hayro-interpret/src/x_object.rs b/vendor/hayro-interpret/src/x_object.rs index 304b14a085..6fcb34a5d7 100644 --- a/vendor/hayro-interpret/src/x_object.rs +++ b/vendor/hayro-interpret/src/x_object.rs @@ -1,4 +1,5 @@ use crate::cache::{Cache, DecodedImageCache, DecodedImageKey}; +use crate::cmyk_group::{CmykGroup, blends_in_device_cmyk, names_a_blending_space}; use crate::color::{ColorComponents, ColorSpace, DefaultColorSpaces, DeviceFamily, ToRgb}; use crate::context::Context; use crate::device::Device; @@ -138,8 +139,6 @@ pub(crate) fn draw_form_xobject<'a, 'b>( return; } - let iter = TypedIter::new(x_object.decoded.as_ref()); - context.path_mut().truncate(0); context.save_state(); context.pre_concat_affine(x_object.matrix); @@ -155,13 +154,14 @@ pub(crate) fn draw_form_xobject<'a, 'b>( // 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 entered_plainly = { 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 neutral = !x_object.is_isolated && !x_object.is_knockout && entered_plainly; // PdfCraft patch (39): a group drawn as an element of a knockout group replaces what that // group drew under its shape, the union of its own elements' shapes (#2971). The shape is // cleared by drawing the group's content once in the device's erase mode; the group then @@ -193,6 +193,26 @@ pub(crate) fn draw_form_xobject<'a, 'b>( context.restore_state(device); context.path_mut().truncate(0); } + // PdfCraft patch (67): a non-isolated knockout group entered the same way composites each + // element with the backdrop already on the page, in the element's own blend mode (#4886). A + // layer of its own starts transparent, where a blend mode has nothing to blend with, so the + // group is drawn in passes, one per blend mode, each composited onto the page in its mode + // (`Device::begin_knockout_pass`). + if x_object.is_transparency_group + && x_object.is_knockout + && !x_object.is_isolated + && entered_plainly + && !knockout_element + { + draw_knockout_passes(x_object, resources, context, device); + context.pop_root_transform(); + context.restore_state(device); + if has_oc { + context.ocg_state.end_marked_content(); + } + context.end_nested_interpretation(); + return; + } let own_layer = x_object.is_transparency_group && (!neutral || knockout_element); if own_layer { @@ -210,6 +230,57 @@ pub(crate) fn draw_form_xobject<'a, 'b>( } } + let resources = Resources::for_form(x_object.resources.clone(), resources.clone()); + + // PdfCraft patch (70): an isolated group blending in DeviceCMYK starts from a backdrop of + // its own, so a device able to blend in CMYK computes its interior apart and composites the + // result through the layer pushed above (see `cmyk_group`). Knockout groups are left out: + // their elements composite with the group's initial backdrop, not with each other. + // An isolated group that names another blending space blends in that space (§11.3.4); a + // device drawing an enclosing group in CMYK is told, so it can decline that group. A + // non-isolated group's /CS is ignored: it blends in its parent's space (ISO 32000-2 + // Table 145, CS), so it stays in the enclosing group's CMYK. + if x_object.is_transparency_group + && x_object.is_isolated + && names_a_blending_space(&x_object.dict) + && !blends_in_device_cmyk(&x_object.dict) + { + device.group_blends_outside_device_cmyk(); + } + + let blended_in_cmyk = own_layer + && x_object.is_isolated + && !x_object.is_knockout + && blends_in_device_cmyk(&x_object.dict) + && device.draw_isolated_cmyk_group(&CmykGroup::new(x_object, &resources, context)); + + if !blended_in_cmyk { + interpret_form_contents(&resources, x_object, context, device); + } + + if own_layer { + device.pop_transparency_group(); + } + + context.pop_root_transform(); + context.restore_state(device); + + if has_oc { + context.ocg_state.end_marked_content(); + } + + context.end_nested_interpretation(); +} + +/// The contents of form `x_object`, in `context` as prepared for them (the form's `/Matrix` +/// applied, its root transform pushed), clipped to its `/BBox`. PdfCraft patch (70): shared by +/// [`draw_form_xobject`] and [`CmykGroup::interpret`]. +pub(crate) fn interpret_form_contents<'a>( + resources: &Resources<'a>, + x_object: &FormXObject<'a>, + context: &mut Context<'a>, + device: &mut impl Device<'a>, +) { device.set_soft_mask(context.get().graphics_state.soft_mask.clone()); device.set_blend_mode(context.get().graphics_state.blend_mode); @@ -226,26 +297,70 @@ pub(crate) fn draw_form_xobject<'a, 'b>( }); interpret( - iter, - &Resources::for_form(x_object.resources.clone(), resources.clone()), + TypedIter::new(x_object.decoded.as_ref()), + resources, context, device, ); device.pop_clip_path(); +} - if own_layer { +/// PdfCraft patch (67): draws a non-isolated knockout group entered at full opacity, without a +/// soft mask and with Normal blending, once per blend mode its elements use. The Normal pass +/// finds the other modes; there are at most as many passes as blend modes. +fn draw_knockout_passes<'a>( + x_object: &FormXObject<'a>, + resources: &Resources<'a>, + context: &mut Context<'a>, + device: &mut impl Device<'a>, +) { + let clip = ClipPath { + path: context.get().ctm + * Rect::new( + x_object.bbox[0] as f64, + x_object.bbox[1] as f64, + x_object.bbox[2] as f64, + x_object.bbox[3] as f64, + ) + .to_path(0.1), + fill: FillRule::NonZero, + }; + // Each pass starts from the same marked-content state, also after a stream that leaves a + // `BDC` open. + let visibility = context.ocg_state.visibility(); + let mut modes = vec![BlendMode::Normal]; + let mut pass = 0; + while let Some(&mode) = modes.get(pass) { + context.ocg_state.set_visibility(visibility.clone()); + context.save_state(); + device.push_transparency_group(1.0, None, mode); + device.set_knockout(); + device.begin_knockout_pass(mode); + device.set_soft_mask(None); + device.set_blend_mode(BlendMode::Normal); + device.push_clip_path(&clip); + interpret( + TypedIter::new(x_object.decoded.as_ref()), + &Resources::for_form(x_object.resources.clone(), resources.clone()), + context, + device, + ); + device.pop_clip_path(); + let others = device.end_knockout_pass(); device.pop_transparency_group(); + context.restore_state(device); + context.path_mut().truncate(0); + if pass == 0 { + for other in others { + if !modes.contains(&other) { + modes.push(other); + } + } + } + pass += 1; } - - context.pop_root_transform(); - context.restore_state(device); - - if has_oc { - context.ocg_state.end_marked_content(); - } - - context.end_nested_interpretation(); + context.ocg_state.set_visibility(visibility); } pub(crate) fn draw_image_xobject<'a, 'b>( @@ -566,13 +681,23 @@ fn decoded_mask_size(mask: &DecodedMask) -> usize { /// `/Filter` or its inline abbreviation `/F`. Errs towards `true`: a name the decoder would /// ignore still counts, which only keeps the target in the key as before. fn uses_jpx(stream: &Stream<'_>) -> bool { + uses_filter(stream, JPX_DECODE) +} + +/// PdfCraft patch (73): whether `stream` is JBIG2, so that what the page has left, which all of it +/// shares, says something about why this stream failed. +fn uses_jbig2(stream: &Stream<'_>) -> bool { + uses_filter(stream, JBIG2_DECODE) +} + +fn uses_filter(stream: &Stream<'_>, filter: &[u8]) -> bool { let dict = stream.dict(); - let is_jpx = |name: &Name<'_>| name.deref() == JPX_DECODE; + let is_filter = |name: &Name<'_>| name.deref() == filter; [F, FILTER].into_iter().any(|key| { - dict.get::>(key).is_some_and(|name| is_jpx(&name)) + dict.get::>(key).is_some_and(|name| is_filter(&name)) || dict .get::>(key) - .is_some_and(|filters| filters.iter::>().any(|name| is_jpx(&name))) + .is_some_and(|filters| filters.iter::>().any(|name| is_filter(&name))) }) } @@ -640,7 +765,15 @@ fn decode_context<'a>( let decoded = obj .stream .decoded_image(&decode_params) - .map_err(|_| (obj.warning_sink)(InterpreterWarning::ImageDecodeFailure)) + .map_err(|_| { + // PdfCraft patch (73): left out for the page's allowance, it is content the page's + // budget skipped, which is reported, and no failure of this image. + (obj.warning_sink)(if hayro_syntax::page_jbig2_work_spent() && uses_jbig2(&obj.stream) { + InterpreterWarning::ContentTruncated + } else { + InterpreterWarning::ImageDecodeFailure + }); + }) .ok()?; let (mut scale_x, mut scale_y) = (1.0, 1.0); @@ -893,6 +1026,15 @@ fn decode_raster( .flatten() }; + // PdfCraft patch (73): a bad mask leaves the image as it is, but one left out for the page's + // allowance takes the image with it. Both are reported, and only here is the file not to blame + // for what the mask would have hidden being shown. + let dict = obj.stream.dict(); + let mask = dict.get::>(SMASK).or_else(|| dict.get::>(MASK)); + if alpha.is_none() && hayro_syntax::page_jbig2_work_spent() && mask.is_some_and(|m| uses_jbig2(&m)) { + return None; + } + Some(DecodedRaster { image, alpha }) } diff --git a/vendor/hayro-jbig2/src/bitmap.rs b/vendor/hayro-jbig2/src/bitmap.rs index 35ed975615..87738a849d 100644 --- a/vendor/hayro-jbig2/src/bitmap.rs +++ b/vendor/hayro-jbig2/src/bitmap.rs @@ -19,6 +19,41 @@ pub(crate) const MAX_DIMENSION: u32 = u16::MAX as u32; /// pixels, decoded one by one. pub(crate) const MAX_PIXELS: u64 = 1 << 28; +/// PdfCraft patch: the most cells a halftone grid may have, which still gives the largest page +/// (see [`MAX_PIXELS`]) cells of 3 × 3, unrotated. Each cell takes a 4-byte value while the grid decodes, so +/// the pixel bound alone let a 98-byte segment take 1 GiB; this one leaves 128 MiB. +pub(crate) const MAX_GRID_CELLS: u64 = 1 << 25; + +/// PdfCraft patch: the decoding work a stream may ask for, in pixels decoded from their code: four +/// of the largest pages, about 6 s on data the decoder predicts and about 17 s on data it can't, so +/// a render finishes inside its 20 s watchdog. Each bitmap is bounded on its own, but nothing +/// bounded how many there are: 1,140 bytes of generic regions took over 40 s, a 795-byte PDF's one +/// halftone 265 s, and a 923-byte PDF's regions would have taken days. +pub(crate) const MAX_WORK: u64 = 4 * MAX_PIXELS; + +/// PdfCraft patch: the work of reading one integer of an item, what an arithmetic-coded one takes +/// at worst in decisions (see [`MAX_WORK`]), so that a decision costs no more than a pixel. An item +/// with no pixels still reads its integers: charged a step each, a 22,515-byte PDF of refined text +/// instances of no size stayed inside `MAX_WORK` and took 730 s. +pub(crate) const INTEGER_WORK: u64 = 38; + +/// PdfCraft patch: the work of a line of a Huffman table built for symbols, about 50 ns (see +/// [`MAX_WORK`]). +pub(crate) const HUFFMAN_LINE_WORK: u64 = 10; + +/// PdfCraft patch: the work of decoding a bitmap pixel by pixel (see [`MAX_WORK`]). A row costs up +/// to 21 ns however few pixels it has, about 4 pixels' worth, so one of no width isn't free: 16,384 +/// regions of 0 × 65535 took 9 s at a cost of nothing. +pub(crate) fn decode_work(width: u32, height: u32) -> u64 { + u64::from(height) * (u64::from(width) + 4) +} + +/// PdfCraft patch: the work of copying a bitmap (see [`MAX_WORK`]): a word at a time, and a row is +/// a copy of its own, so a tall thin bitmap costs its rows, not its pixels. +pub(crate) fn copy_work(width: u32, height: u32) -> u64 { + u64::from(height) * (u64::from(width.div_ceil(WORD_BITS)) + 1) +} + /// The underlying word type for packed pixel storage. /// Change this to `u32` to switch back to 32-bit words. pub(crate) type Word = u32; @@ -26,6 +61,23 @@ pub(crate) type Word = u32; pub(crate) const WORD_BITS: u32 = Word::BITS; pub(crate) const WORD_SHIFT: u32 = WORD_BITS - 1; +/// PdfCraft patch: the words a [`Bitmap`] takes before its pixels. A symbol of width 0 has no +/// pixels and still costs this: 5 KB of zero-width dictionaries held 781 MB. +pub(crate) const BITMAP_WORDS: usize = size_of::() / size_of::(); + +/// PdfCraft patch: refuse a page past the bounds on a bitmap (see `MAX_PIXELS`). A stream is held +/// to this when it is opened as well as when its page bitmap is sized: a caller sizes its own +/// output from the page header in between, and a 743-byte PDF's Indexed image asked for 137 GB. +pub(crate) fn check_page_size(width: u32, height: u32) -> Result<()> { + if width > MAX_DIMENSION || height > MAX_DIMENSION { + return Err(OverflowError::BitmapDimension.into()); + } + if u64::from(width) * u64::from(height) > MAX_PIXELS { + return Err(OverflowError::BitmapDimension.into()); + } + Ok(()) +} + /// A decoded bitmap with position information. /// /// "Pixels decoded by the MMR decoder having the value 'black' shall be treated @@ -51,6 +103,11 @@ pub(crate) struct Bitmap { } impl Bitmap { + /// PdfCraft patch: the words this bitmap holds, its header included. + pub(crate) fn words(&self) -> usize { + self.data.len().saturating_add(BITMAP_WORDS) + } + /// Create a new bitmap filled with white pixels (false). /// /// The bitmap is positioned at (0, 0). @@ -93,13 +150,7 @@ impl Bitmap { height: u32, default_pixel: bool, ) -> Result<()> { - if width > MAX_DIMENSION || height > MAX_DIMENSION { - return Err(OverflowError::BitmapDimension.into()); - } - // PdfCraft patch: see `MAX_PIXELS` (the page bitmap is sized here). - if u64::from(width) * u64::from(height) > MAX_PIXELS { - return Err(OverflowError::BitmapDimension.into()); - } + check_page_size(width, height)?; let stride = width.div_ceil(WORD_BITS); let default_word: Word = if default_pixel { !0 } else { 0 }; @@ -149,7 +200,8 @@ impl Bitmap { // TODO: Figure out how we can optimize this, especially since this is a // bottleneck for symbols/patterns. let dest_x_start = x.max(0); - let dest_x_end = (x + other.width as i32).min(self.width as i32); + // Saturating: a location can be anywhere an `i32` reaches, and past the end it is off the page. + let dest_x_end = x.saturating_add(other.width as i32).min(self.width as i32); if dest_x_start >= dest_x_end { return; @@ -160,7 +212,7 @@ impl Bitmap { let dest_x_end = dest_x_end as u32; for src_y in 0..other.height { - let dest_y = y + src_y as i32; + let dest_y = y.saturating_add(src_y as i32); if dest_y < 0 || dest_y >= self.height as i32 { continue; } diff --git a/vendor/hayro-jbig2/src/decode/generic_refinement.rs b/vendor/hayro-jbig2/src/decode/generic_refinement.rs index a566b8de58..a6634439d4 100644 --- a/vendor/hayro-jbig2/src/decode/generic_refinement.rs +++ b/vendor/hayro-jbig2/src/decode/generic_refinement.rs @@ -179,10 +179,12 @@ impl<'a> RefinementContextGatherer<'a> { pub(crate) fn start_row(&mut self, region: &Bitmap, reference: &Bitmap, y: u32) { self.reg_y = y; - let ref_y = y as i32 - self.reference_dy; + // The offsets can be anywhere an `i32` reaches, so coordinates in the reference wrap here and + // below. Wrapped, they are off the reference, whose pixels there read as 0. + let ref_y = (y as i32).wrapping_sub(self.reference_dy); // This will on purpose wrap to a very large `u32` for negative values, such that // they resolve to 0 in `ContextGatherer::load_word`. - let ref_x0 = (-self.reference_dx) as u32; + let ref_x0 = self.reference_dx.wrapping_neg() as u32; self.reg_cur_x = 0; self.reg_m1 = if y >= 1 { @@ -193,9 +195,9 @@ impl<'a> RefinementContextGatherer<'a> { self.reg_cur = 0; self.ref_cur_x = 0; - self.ref_m1 = ContextGatherer::load_word(reference, (ref_y - 1) as u32, 0); + self.ref_m1 = ContextGatherer::load_word(reference, ref_y.wrapping_sub(1) as u32, 0); self.ref_cur = ContextGatherer::load_word(reference, ref_y as u32, 0); - self.ref_p1 = ContextGatherer::load_word(reference, (ref_y + 1) as u32, 0); + self.ref_p1 = ContextGatherer::load_word(reference, ref_y.wrapping_add(1) as u32, 0); self.ctx = match self.template { RefinementTemplate::Template1 => { @@ -257,7 +259,7 @@ impl<'a> RefinementContextGatherer<'a> { self.reg_cur = ContextGatherer::load_word(region, self.reg_y, new_start); } - let ref_x_signed = x as i32 - self.reference_dx; + let ref_x_signed = (x as i32).wrapping_sub(self.reference_dx); if ref_x_signed < 0 { self.ref_cur_x = 0; self.ref_m1 = 0; @@ -268,18 +270,20 @@ impl<'a> RefinementContextGatherer<'a> { if ref_x + 1 >= self.ref_cur_x + WORD_BITS || ref_x < self.ref_cur_x { let new_start = ref_x.saturating_sub(1); - let ref_y = self.reg_y as i32 - self.reference_dy; + let ref_y = (self.reg_y as i32).wrapping_sub(self.reference_dy); self.ref_cur_x = new_start; - self.ref_m1 = ContextGatherer::load_word(reference, (ref_y - 1) as u32, new_start); + self.ref_m1 = + ContextGatherer::load_word(reference, ref_y.wrapping_sub(1) as u32, new_start); self.ref_cur = ContextGatherer::load_word(reference, ref_y as u32, new_start); - self.ref_p1 = ContextGatherer::load_word(reference, (ref_y + 1) as u32, new_start); + self.ref_p1 = + ContextGatherer::load_word(reference, ref_y.wrapping_add(1) as u32, new_start); } } } #[inline(always)] pub(crate) fn tpgr_all_same(&self, x: u32) -> bool { - let ref_x = (x as i32 - self.reference_dx) as u32; + let ref_x = (x as i32).wrapping_sub(self.reference_dx) as u32; let rbx = ref_x.wrapping_sub(self.ref_cur_x); #[inline(always)] @@ -310,7 +314,7 @@ impl<'a> RefinementContextGatherer<'a> { #[inline(always)] pub(crate) fn ref_center_pixel(&self, x: u32) -> u8 { - let ref_x = (x as i32 - self.reference_dx) as u32; + let ref_x = (x as i32).wrapping_sub(self.reference_dx) as u32; let rbx = ref_x.wrapping_sub(self.ref_cur_x); ContextGatherer::get_buf_pixel(self.ref_cur, rbx) as u8 @@ -329,7 +333,7 @@ impl<'a> RefinementContextGatherer<'a> { #[inline(always)] fn gather_template0_default(&mut self, _region: &Bitmap, _reference: &Bitmap, x: u32) -> u16 { let bx = x - self.reg_cur_x; - let ref_x = (x as i32 - self.reference_dx) as u32; + let ref_x = (x as i32).wrapping_sub(self.reference_dx) as u32; let rbx = ref_x.wrapping_sub(self.ref_cur_x); let new_pixels = (ContextGatherer::get_buf_pixel(self.reg_m1, bx + 1) << 10) @@ -345,12 +349,12 @@ impl<'a> RefinementContextGatherer<'a> { #[inline(always)] fn gather_template0_custom(&mut self, region: &Bitmap, reference: &Bitmap, x: u32) -> u16 { let bx = x - self.reg_cur_x; - let ref_x_i = x as i32 - self.reference_dx; + let ref_x_i = (x as i32).wrapping_sub(self.reference_dx); let rbx = ref_x_i as u32 - self.ref_cur_x; let xi = x as i32; let yi = self.reg_y as i32; - let ref_y = yi - self.reference_dy; + let ref_y = yi.wrapping_sub(self.reference_dy); let at1 = self.at_pixels[0]; let at2 = self.at_pixels[1]; @@ -360,8 +364,8 @@ impl<'a> RefinementContextGatherer<'a> { | (ContextGatherer::get_buf_pixel(self.reg_m1, bx + 1) << 10) | (ContextGatherer::get_buf_pixel(self.reg_cur, bx.wrapping_sub(1)) << 9) | ((reference.get_pixel( - (ref_x_i + at2.x as i32) as u32, - (ref_y + at2.y as i32) as u32, + ref_x_i.wrapping_add(at2.x as i32) as u32, + ref_y.wrapping_add(at2.y as i32) as u32, ) as u16) << 8) | (ContextGatherer::get_buf_pixel(self.ref_m1, rbx.wrapping_add(1)) << 6) @@ -375,7 +379,7 @@ impl<'a> RefinementContextGatherer<'a> { #[inline(always)] fn gather_template1(&mut self, _region: &Bitmap, _reference: &Bitmap, x: u32) -> u16 { let bx = x - self.reg_cur_x; - let ref_x = (x as i32 - self.reference_dx) as u32; + let ref_x = (x as i32).wrapping_sub(self.reference_dx) as u32; let rbx = ref_x.wrapping_sub(self.ref_cur_x); let new_pixels = (ContextGatherer::get_buf_pixel(self.reg_m1, bx + 1) << 7) diff --git a/vendor/hayro-jbig2/src/decode/halftone.rs b/vendor/hayro-jbig2/src/decode/halftone.rs index eb5c1c21e8..10000f801b 100644 --- a/vendor/hayro-jbig2/src/decode/halftone.rs +++ b/vendor/hayro-jbig2/src/decode/halftone.rs @@ -7,7 +7,7 @@ use super::RegionBitmap; use super::pattern::PatternDictionary; use super::{CombinationOperator, RegionSegmentInfo, Template, parse_region_segment_info}; use crate::ScratchBuffers; -use crate::bitmap::{Bitmap, MAX_DIMENSION, WORD_BITS}; +use crate::bitmap::{Bitmap, MAX_DIMENSION, WORD_BITS, copy_work, decode_work}; use crate::error::{OverflowError, ParseError, RegionError, Result, bail}; use crate::gray_scale::{GrayScaleParams, decode_gray_scale_image}; use crate::reader::Reader; @@ -48,11 +48,7 @@ pub(crate) fn decode_into( None }; - // "3) Set HBPP to ⌈log₂(HNUMPATS)⌉." (6.6.5) - let bits_per_pixel = (pattern_dict.patterns.len() as u32) - .saturating_sub(1) - .checked_ilog2() - .map_or(1, |n| n + 1); + let bits_per_pixel = bits_per_pixel(pattern_dict); // "4) Decode an image GI of size HGW by HGH with HBPP bits per pixel using // the gray-scale image decoding procedure as described in Annex C." (6.6.5) @@ -73,6 +69,28 @@ pub(crate) fn decode_into( Ok(()) } +/// PdfCraft patch: the work of a halftone region (see `bitmap::MAX_WORK`): a bit plane of the grid +/// for each bit of a pattern's index, a pattern copied for each cell, and the region itself. One +/// such segment in a 795-byte PDF took 265 s. +pub(crate) fn work(header: &HalftoneRegionHeader<'_>, patterns: &PatternDictionary) -> u64 { + let grid = &header.grid_position_and_size; + let cells = u64::from(grid.width) * u64::from(grid.height); + let planes = u64::from(bits_per_pixel(patterns)); + let copied = copy_work(patterns.pattern_width, patterns.pattern_height); + planes + .saturating_mul(decode_work(grid.width, grid.height)) + .saturating_add(cells.saturating_mul(copied)) + .saturating_add(copy_work(header.region_info.width, header.region_info.height)) +} + +/// "3) Set HBPP to ⌈log₂(HNUMPATS)⌉." (6.6.5) +fn bits_per_pixel(patterns: &PatternDictionary) -> u32 { + (patterns.patterns.len() as u32) + .saturating_sub(1) + .checked_ilog2() + .map_or(1, |n| n + 1) +} + /// Parse a halftone region segment header (7.4.5.1). pub(crate) fn parse<'a>(reader: &mut Reader<'a>) -> Result> { let region_info = parse_region_segment_info(reader)?; @@ -97,8 +115,8 @@ pub(crate) fn parse<'a>(reader: &mut Reader<'a>) -> Result MAX_DIMENSION || grid_height > MAX_DIMENSION { bail!(OverflowError::GridDimension); } - // PdfCraft patch: the product is bounded too (see `bitmap::MAX_PIXELS`). - if u64::from(grid_width) * u64::from(grid_height) > crate::bitmap::MAX_PIXELS { + // PdfCraft patch: the product is bounded too (see `bitmap::MAX_GRID_CELLS`). + if u64::from(grid_width) * u64::from(grid_height) > crate::bitmap::MAX_GRID_CELLS { bail!(OverflowError::GridDimension); } diff --git a/vendor/hayro-jbig2/src/decode/pattern.rs b/vendor/hayro-jbig2/src/decode/pattern.rs index 2c15d70b8f..03d4d8eb89 100644 --- a/vendor/hayro-jbig2/src/decode/pattern.rs +++ b/vendor/hayro-jbig2/src/decode/pattern.rs @@ -3,15 +3,20 @@ use super::{AdaptiveTemplatePixel, Template, generic}; use crate::ScratchBuffers; use crate::arithmetic_decoder::{ArithmeticDecoder, ArithmeticDecoderContext}; -use crate::bitmap::{Bitmap, WORD_BITS, Word}; +use crate::bitmap::{Bitmap, WORD_BITS, Word, copy_work}; use crate::error::{OverflowError, ParseError, Result}; use crate::reader::Reader; use alloc::vec::Vec; +/// PdfCraft patch: the most words of shifted copies a dictionary keeps, 16 MiB, where 256 patterns +/// of 32 rows take 2 MiB. A 48-byte dictionary of 65,535 such patterns asked for 539 MB. +const MAX_SHIFTED_WORDS: u64 = 1 << 22; + /// Decode a pattern dictionary segment (7.4.4.2, 6.7). pub(crate) fn decode( header: &PatternDictionaryHeader<'_>, scratch: &mut ScratchBuffers, + room: u64, ) -> Result { let pattern_width = header.pattern_width as u32; let pattern_height = header.pattern_height as u32; @@ -133,7 +138,19 @@ pub(crate) fn decode( // for all possible 32-bit alignments, which later on allows us to // very easily blit it into the backdrop, significantly speeding up // the whole process. - let shifted_patterns = if pattern_width <= WORD_BITS && pattern_height <= WORD_BITS { + // + // PdfCraft patch: only when the table is small, and fits what the page may still hold + // (`room`), so that a speed-up never gets an image refused. It can be left out: + // `shifted_pattern` then returns `None` and the patterns are combined the slow way. + let shifted_words = (patterns.len() as u64) + .saturating_mul(32 * 2) + .saturating_mul(u64::from(pattern_height)); + let pattern_words = patterns.iter().map(|p| p.words() as u64).fold(0, u64::saturating_add); + let shifted_patterns = if pattern_width <= WORD_BITS + && pattern_height <= WORD_BITS + && shifted_words <= MAX_SHIFTED_WORDS + && shifted_words.saturating_add(pattern_words) <= room + { let h = pattern_height as usize; let mut data = Vec::with_capacity(patterns.len() * 32 * h * 2); for pattern in &patterns { @@ -180,6 +197,11 @@ impl PatternDictionary { let start = (pattern_idx * 32 + bit_offset as usize) * len; self.shifted_patterns.get(start..start + len) } + + /// PdfCraft patch: the words the dictionary holds, its patterns and their shifted copies. + pub(crate) fn words(&self) -> usize { + self.patterns.iter().map(Bitmap::words).fold(self.shifted_patterns.len(), usize::saturating_add) + } } /// Parsed pattern dictionary segment header (7.4.4.1). @@ -196,6 +218,17 @@ pub(crate) struct PatternDictionaryHeader<'a> { pub(crate) data: &'a [u8], } +impl PatternDictionaryHeader<'_> { + /// PdfCraft patch: the work of decoding the patterns, side by side in one bitmap, and of + /// taking each out of it (see `bitmap::MAX_WORK`). + pub(crate) fn work(&self) -> u64 { + let (width, height) = (u32::from(self.pattern_width), u32::from(self.pattern_height)); + let patterns = u64::from(self.num_patterns) + 1; + let side_by_side = u64::from(height) * (patterns.saturating_mul(u64::from(width)) + 4); + side_by_side.saturating_add(patterns.saturating_mul(copy_work(width, height))) + } +} + /// Parse a pattern dictionary segment header (7.4.4.1). pub(crate) fn parse<'a>(reader: &mut Reader<'a>) -> Result> { let flags_byte = reader.read_byte().ok_or(ParseError::UnexpectedEof)?; diff --git a/vendor/hayro-jbig2/src/decode/symbol.rs b/vendor/hayro-jbig2/src/decode/symbol.rs index f1aa8a6639..b682d4571c 100644 --- a/vendor/hayro-jbig2/src/decode/symbol.rs +++ b/vendor/hayro-jbig2/src/decode/symbol.rs @@ -4,7 +4,7 @@ use alloc::vec; use alloc::vec::Vec; use crate::arithmetic_decoder::{ArithmeticDecoder, ArithmeticDecoderContext}; -use crate::bitmap::{Bitmap, WORD_BITS}; +use crate::bitmap::{BITMAP_WORDS, Bitmap, HUFFMAN_LINE_WORK, INTEGER_WORK, WORD_BITS, Word, decode_work}; use crate::decode::generic::{decode_bitmap_mmr, parse_adaptive_template_pixels}; use crate::decode::text::{ DecodeContext, ReferenceCorner, TextRegionContexts, TextRegionFlags, TextRegionHeader, @@ -45,6 +45,8 @@ pub(crate) fn decode( referred_tables: &[HuffmanTable], standard_tables: &StandardHuffmanTables, input_contexts: Option<&RetainedContexts>, + room: u64, + work_left: &mut u64, ) -> Result { let num_new_symbols = header.num_new_symbols; @@ -58,6 +60,9 @@ pub(crate) fn decode( total_width: 0, height_class_height: 0, pixels: 0, + new_words: 0, + room, + work_left, header, standard_tables, }; @@ -96,6 +101,8 @@ pub(crate) fn decode( bail!(SymbolError::Invalid); } height_classes_left -= 1; + // PdfCraft patch: see `bitmap::INTEGER_WORK`. A class of no symbols still reads its delta. + *ctx.work_left = ctx.work_left.checked_sub(INTEGER_WORK).ok_or(OverflowError::DecodingWork)?; let height_class_delta = read_height_class_delta(&mut ctx)?.ok_or(SymbolError::UnexpectedOob)?; @@ -130,6 +137,19 @@ pub(crate) fn decode( if ctx.pixels > MAX_DICTIONARY_PIXELS { bail!(OverflowError::BitmapDimension); } + // PdfCraft patch: see `bitmap::INTEGER_WORK`. Its width, and a stream can hold thousands + // of dictionaries of symbols of no pixels. An aggregated one reads its count, a symbol + // and two offsets as well. + let integers = if ctx.header.flags.use_refagg { 5 } else { 1 }; + let work = decode_work(symbol_width, ctx.height_class_height).saturating_add(integers * INTEGER_WORK); + *ctx.work_left = ctx.work_left.checked_sub(work).ok_or(OverflowError::DecodingWork)?; + // PdfCraft patch: see `room`. Rows take a word at least, so symbols of one column + // held 270 MiB within the pixel bound, however few the dictionary exported. + let rows = u64::from(symbol_width.div_ceil(WORD_BITS)) * u64::from(ctx.height_class_height); + ctx.new_words = ctx.new_words.saturating_add(rows).saturating_add(BITMAP_WORDS as u64); + if ctx.new_words > ctx.room { + bail!(OverflowError::RetainedBitmaps); + } match (ctx.header.flags.use_huffman, ctx.header.flags.use_refagg) { (false, false) => { @@ -202,6 +222,18 @@ pub(crate) struct SymbolDictionary { pub(crate) retained_contexts: Option, } +impl SymbolDictionary { + /// PdfCraft patch: the words the dictionary holds, its retained contexts included. Those are + /// a byte each, and 1.1 MB of dictionaries that retain them, 103 KB behind Flate, held 2.89 GB. + pub(crate) fn words(&self) -> usize { + let contexts = self.retained_contexts.as_ref().map_or(0, |contexts| { + let bytes = contexts.generic_region.len().saturating_add(contexts.refinement_region.len()); + bytes.saturating_mul(size_of::()) / size_of::() + }); + self.exported_symbols.iter().map(Bitmap::words).fold(contexts, usize::saturating_add) + } +} + /// Decode a symbol bitmap using refinement/aggregate coding (6.5.8.2). fn decode_refinement_aggregation_bitmap( ctx: &mut SymbolDecodeContext<'_>, @@ -337,6 +369,11 @@ fn decode_aggregation_bitmap( ) -> Result { let use_huffman = ctx.header.flags.use_huffman; + // PdfCraft patch: see `bitmap::MAX_WORK`. What follows is sized by all the symbols, for each + // aggregated symbol, at about 50 ns a symbol: a 713-byte PDF asked for an hour. + let work = u64::from(ctx.total_symbols()).saturating_mul(HUFFMAN_LINE_WORK); + *ctx.work_left = ctx.work_left.checked_sub(work).ok_or(OverflowError::DecodingWork)?; + // Concatenate input and new symbols. let mut all_symbols: Vec<&Bitmap> = Vec::with_capacity(ctx.symbols.input.len() + ctx.symbols.new.len()); @@ -422,7 +459,7 @@ fn decode_aggregation_bitmap( header.region_info.y_location, header.flags.default_pixel, )?; - decode_with(decode_ctx, &all_symbols, &header, &mut bitmap)?; + decode_with(decode_ctx, &all_symbols, &header, &mut bitmap, ctx.work_left)?; Ok(bitmap) } @@ -463,6 +500,14 @@ struct SymbolDecodeContext<'a> { symbols_decoded_count: u32, total_width: u32, height_class_height: u32, + /// PdfCraft patch: the words the page may still hold, which neither the new symbols nor the + /// exported ones may pass. They are counted as they are made: counted only once the + /// dictionary was stored, 8 MiB symbols exported again and again killed the process. + room: u64, + /// PdfCraft patch: the words of the new symbols so far (see `room`). + new_words: u64, + /// PdfCraft patch: the decoding work the stream may still ask for (see `bitmap::MAX_WORK`). + work_left: &'a mut u64, /// PdfCraft patch: symbol pixels decoded so far (see `MAX_DICTIONARY_PIXELS`). pixels: u64, } @@ -612,6 +657,17 @@ fn decode_height_class_collective_bitmap(ctx: &mut SymbolDecodeContext<'_>) -> R ctx.h_ctx.reader.align(); + // PdfCraft patch: a class of no symbols has nothing to cut out, and `bitmap::decode_work` charges + // rows by the symbol, so decoding this would be rows for nothing: 65,535 a class, and a dictionary + // can hold a thousand such classes. Its bytes still have to be stepped over. + if ctx.symbols_decoded_count == ctx.height_class_first_symbol { + ctx.h_ctx + .reader + .read_bytes(bitmap_size as usize) + .ok_or(ParseError::UnexpectedEof)?; + return Ok(()); + } + let collective_bitmap = if bitmap_size == 0 { // Bitmap is stored uncompressed, so we basically just copy-paste // each byte. Rows are padded to the byte boundary. @@ -708,6 +764,7 @@ fn export_symbols(ctx: &mut SymbolDecodeContext<'_>) -> Result> { u32::try_from(value).map_err(|_| HuffmanError::InvalidCode.into()) }; let mut exported = Vec::with_capacity(ctx.header.num_exported_symbols as usize); + let mut exported_words: u64 = 0; let mut index: u32 = 0; let mut should_export = false; // PdfCraft patch: see `EXTRA_EMPTY_STEPS`. @@ -718,6 +775,8 @@ fn export_symbols(ctx: &mut SymbolDecodeContext<'_>) -> Result> { bail!(SymbolError::Invalid); } runs_left -= 1; + // PdfCraft patch: see `bitmap::INTEGER_WORK`. A run of no symbols still reads its length. + *ctx.work_left = ctx.work_left.checked_sub(INTEGER_WORK).ok_or(OverflowError::DecodingWork)?; let run_length = read_run_length()?; let end_index = index.checked_add(run_length).ok_or(OverflowError::Index)?; @@ -730,9 +789,13 @@ fn export_symbols(ctx: &mut SymbolDecodeContext<'_>) -> Result> { let symbol = ctx .symbols .get(symbol_idx as usize) - .ok_or(SymbolError::OutOfRange)? - .clone(); - exported.push(symbol); + .ok_or(SymbolError::OutOfRange)?; + // PdfCraft patch: see `room`. + exported_words = exported_words.saturating_add(symbol.words() as u64); + if exported_words > ctx.room { + bail!(OverflowError::RetainedBitmaps); + } + exported.push(symbol.clone()); } } @@ -864,3 +927,25 @@ pub(crate) fn parse<'a>(reader: &mut Reader<'a>) -> Result Result { let mut bitmap = Bitmap::new_with( header.region_info.width, @@ -41,6 +42,7 @@ pub(crate) fn decode( standard_tables, &mut bitmap, scratch, + work_left, )?; Ok(RegionBitmap { @@ -56,12 +58,13 @@ pub(crate) fn decode_into( standard_tables: &StandardHuffmanTables, bitmap: &mut Bitmap, scratch: &mut ScratchBuffers, + work_left: &mut u64, ) -> Result<()> { if header.flags.use_huffman { let mut reader = Reader::new(header.data); let ctx = DecodeContext::new_huffman(&mut reader, header, referred_tables, standard_tables)?; - decode_with(ctx, symbols, header, bitmap)?; + decode_with(ctx, symbols, header, bitmap, work_left)?; } else { let mut decoder = ArithmeticDecoder::new(header.data); @@ -76,7 +79,7 @@ pub(crate) fn decode_into( .resize(num_gr_contexts, ArithmeticDecoderContext::default()); let ctx = DecodeContext::new_arithmetic(&mut decoder, &mut contexts, &mut scratch.contexts); - decode_with(ctx, symbols, header, bitmap)?; + decode_with(ctx, symbols, header, bitmap, work_left)?; } Ok(()) @@ -88,6 +91,7 @@ pub(crate) fn decode_with( symbols: &[&Bitmap], header: &TextRegionHeader<'_>, region: &mut Bitmap, + work_left: &mut u64, ) -> Result<()> { let strip_size = header.strip_size(); @@ -147,7 +151,7 @@ pub(crate) fn decode_with( let symbol_id = ctx.read_symbol_id()?; let symbol_bitmap = - decode_symbol_instance_bitmap(&mut ctx, symbols, header, symbol_id)?; + decode_symbol_instance_bitmap(&mut ctx, symbols, header, symbol_id, work_left)?; let symbol_bitmap_ref: &Bitmap = match &symbol_bitmap { SymbolBitmap::Reference(idx) => symbols.get(*idx).ok_or(SymbolError::OutOfRange)?, @@ -156,6 +160,11 @@ pub(crate) fn decode_with( let symbol_width = symbol_bitmap_ref.width as i32; let symbol_height = symbol_bitmap_ref.height as i32; + // PdfCraft patch: see `bitmap::MAX_WORK`. An instance of a large symbol costs its copy, + // and 10,000 of one took 64 s. Even one with no pixels reads its four integers. + let work = copy_work(symbol_bitmap_ref.width, symbol_bitmap_ref.height).saturating_add(4 * INTEGER_WORK); + *work_left = work_left.checked_sub(work).ok_or(OverflowError::DecodingWork)?; + if !header.flags.transposed && (header.flags.reference_corner == ReferenceCorner::TopRight || header.flags.reference_corner == ReferenceCorner::BottomRight) @@ -173,23 +182,26 @@ pub(crate) fn decode_with( } let symbol_s = current_s; + // From the corner a symbol is placed by to its far side. The strip and the offset can + // be anywhere an `i32` reaches, and wrapped, the symbol is off the region. + let back = |corner: i32, size: i32| corner.wrapping_sub(size).wrapping_add(1); let (x, y) = if !header.flags.transposed { match header.flags.reference_corner { ReferenceCorner::TopLeft => (symbol_s, symbol_t), - ReferenceCorner::TopRight => (symbol_s - symbol_width + 1, symbol_t), - ReferenceCorner::BottomLeft => (symbol_s, symbol_t - symbol_height + 1), + ReferenceCorner::TopRight => (back(symbol_s, symbol_width), symbol_t), + ReferenceCorner::BottomLeft => (symbol_s, back(symbol_t, symbol_height)), ReferenceCorner::BottomRight => { - (symbol_s - symbol_width + 1, symbol_t - symbol_height + 1) + (back(symbol_s, symbol_width), back(symbol_t, symbol_height)) } } } else { match header.flags.reference_corner { ReferenceCorner::TopLeft => (symbol_t, symbol_s), - ReferenceCorner::TopRight => (symbol_t - symbol_width + 1, symbol_s), - ReferenceCorner::BottomLeft => (symbol_t, symbol_s - symbol_height + 1), + ReferenceCorner::TopRight => (back(symbol_t, symbol_width), symbol_s), + ReferenceCorner::BottomLeft => (symbol_t, back(symbol_s, symbol_height)), ReferenceCorner::BottomRight => { - (symbol_t - symbol_width + 1, symbol_s - symbol_height + 1) + (back(symbol_t, symbol_width), back(symbol_s, symbol_height)) } } }; @@ -531,6 +543,7 @@ fn decode_symbol_instance_bitmap( symbols: &[&Bitmap], header: &TextRegionHeader<'_>, symbol_id: usize, + work_left: &mut u64, ) -> Result { if !header.flags.use_refinement || ctx.read_refinement_flag()? == 0 { return Ok(SymbolBitmap::Reference(symbol_id)); @@ -562,6 +575,11 @@ fn decode_symbol_instance_bitmap( .checked_add(rdy) .ok_or(OverflowError::ReferenceOffset)?; + // PdfCraft patch: see `bitmap::MAX_WORK`. Before it is decoded: charged after, one instance + // could still be 2^28 pixels past what was left. With the four integers that gave its size. + let work = decode_work(refined_width, refined_height).saturating_add(4 * INTEGER_WORK); + *work_left = work_left.checked_sub(work).ok_or(OverflowError::DecodingWork)?; + let mut refined_bitmap = Bitmap::new(refined_width, refined_height)?; ctx.decode_refinement_bitmap( diff --git a/vendor/hayro-jbig2/src/error.rs b/vendor/hayro-jbig2/src/error.rs index c579368b44..c8bcf8b811 100644 --- a/vendor/hayro-jbig2/src/error.rs +++ b/vendor/hayro-jbig2/src/error.rs @@ -133,8 +133,14 @@ pub enum OverflowError { ReferenceOffset, /// Index computation overflowed. Index, - /// A symbol dictionary has too many symbols. + /// A symbol dictionary has too many symbols, or a segment refers to too many. SymbolCount, + /// PdfCraft patch: the segments retain more bitmaps than a page may hold. + RetainedBitmaps, + /// PdfCraft patch: a stream has more segments, or references, than it may. + SegmentCount, + /// PdfCraft patch: a stream asks for more decoding work than it may. + DecodingWork, } impl fmt::Display for DecodeError { @@ -161,7 +167,10 @@ impl fmt::Display for OverflowError { Self::ReferenceOffset => write!(f, "overflow in reference offset computation"), Self::Index => write!(f, "overflow in index computation"), Self::GridDimension => write!(f, "overflow in grid dimension computation"), - Self::SymbolCount => write!(f, "a symbol dictionary has too many symbols"), + Self::SymbolCount => write!(f, "too many symbols in a dictionary or referred to"), + Self::RetainedBitmaps => write!(f, "the segments retain too many bitmaps"), + Self::SegmentCount => write!(f, "too many segments or references in a stream"), + Self::DecodingWork => write!(f, "a stream asks for too much decoding work"), } } } diff --git a/vendor/hayro-jbig2/src/file.rs b/vendor/hayro-jbig2/src/file.rs index 1440a22efa..5b8bbe983b 100644 --- a/vendor/hayro-jbig2/src/file.rs +++ b/vendor/hayro-jbig2/src/file.rs @@ -5,12 +5,39 @@ use alloc::vec::Vec; -use crate::error::{FormatError, ParseError, Result, bail}; +use crate::error::{FormatError, OverflowError, ParseError, Result, bail}; use crate::reader::Reader; use crate::segment::{ Segment, SegmentType, parse_segment, parse_segment_data, parse_segment_header, }; +/// PdfCraft patch: the most segments a stream, with its globals, may have. They are all parsed and +/// sorted before the first is decoded, at about 64 bytes for 11 of input, so behind nested Flate a +/// 4,765-byte PDF reached 5 GB and failed only afterwards. +const MAX_SEGMENTS: usize = 1 << 20; + +/// PdfCraft patch: the most references those segments may make, 4 bytes each for 1 of input. +const MAX_REFERENCES: usize = 1 << 22; + +/// PdfCraft patch: the segments of a stream so far (see [`MAX_SEGMENTS`], [`MAX_REFERENCES`]). +#[derive(Default)] +pub(crate) struct SegmentCount { + segments: usize, + references: usize, +} + +impl SegmentCount { + /// Count one more segment, making `references` references. + fn add(&mut self, references: usize) -> Result<()> { + self.segments = self.segments.saturating_add(1); + self.references = self.references.saturating_add(references); + if self.segments > MAX_SEGMENTS || self.references > MAX_REFERENCES { + bail!(OverflowError::SegmentCount); + } + Ok(()) + } +} + /// "There are two standalone file organizations possible for a JBIG2 bitstream." /// (Annex D) #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -140,7 +167,9 @@ fn parse_segments<'a>( let mut segments = Vec::new(); match organization { - FileOrganization::Sequential => parse_segments_sequential(reader, &mut segments)?, + FileOrganization::Sequential => { + parse_segments_sequential(reader, &mut segments, &mut SegmentCount::default())?; + } FileOrganization::RandomAccess => parse_segments_random_access(reader, &mut segments)?, } @@ -155,6 +184,7 @@ fn parse_segments<'a>( pub(crate) fn parse_segments_sequential<'a>( reader: &mut Reader<'a>, segments: &mut Vec>, + count: &mut SegmentCount, ) -> Result<()> { loop { if reader.at_end() { @@ -162,6 +192,7 @@ pub(crate) fn parse_segments_sequential<'a>( } let segment = parse_segment(reader)?; + count.add(segment.header.referred_to_segments.len())?; // "If a file contains an end of file segment, it must be the last segment." // (7.4.11) @@ -187,6 +218,7 @@ fn parse_segments_random_access<'a>( segments: &mut Vec>, ) -> Result<()> { let mut headers = Vec::new(); + let mut count = SegmentCount::default(); loop { if reader.at_end() { @@ -194,6 +226,7 @@ fn parse_segments_random_access<'a>( } let header = parse_segment_header(reader)?; + count.add(header.referred_to_segments.len())?; // "If a file contains an end of file segment, it must be the last segment." // (7.4.11) diff --git a/vendor/hayro-jbig2/src/gray_scale.rs b/vendor/hayro-jbig2/src/gray_scale.rs index 2836ec77a7..4aa4229a5b 100644 --- a/vendor/hayro-jbig2/src/gray_scale.rs +++ b/vendor/hayro-jbig2/src/gray_scale.rs @@ -83,8 +83,8 @@ fn decode_arithmetic( if width > MAX_DIMENSION || height > MAX_DIMENSION { bail!(OverflowError::BitmapDimension); } - // PdfCraft patch: the product is bounded too (see `bitmap::MAX_PIXELS`). - if u64::from(width) * u64::from(height) > crate::bitmap::MAX_PIXELS { + // PdfCraft patch: the product is bounded too (see `bitmap::MAX_GRID_CELLS`). + if u64::from(width) * u64::from(height) > crate::bitmap::MAX_GRID_CELLS { bail!(OverflowError::BitmapDimension); } diff --git a/vendor/hayro-jbig2/src/huffman_table.rs b/vendor/hayro-jbig2/src/huffman_table.rs index 2183c8eea0..1a20da5281 100644 --- a/vendor/hayro-jbig2/src/huffman_table.rs +++ b/vendor/hayro-jbig2/src/huffman_table.rs @@ -14,11 +14,27 @@ include!("huffman_tables_generated.rs"); /// Maximum number of nodes in an inline Huffman table. const INLINE_TABLE_SIZE: usize = 43; +/// PdfCraft patch: the most lines a custom table may have. A line takes 2 bits of input at least +/// and 8 bytes here, so a table reading 250 MiB behind nested Flate, a 1,297-byte PDF, was killed +/// at 8 GiB before it could be counted. +const MAX_TABLE_LINES: usize = 1 << 16; + /// A queryable Huffman table. #[derive(Debug, Clone)] pub(crate) struct HuffmanTable(Rc); impl HuffmanTable { + /// PdfCraft patch: the words this table holds. Every table takes the room of the inline + /// variant, however few nodes it has, and a dynamic one its nodes besides. + pub(crate) fn words(&self) -> usize { + let nodes = match &*self.0 { + InnerHuffmanTable::Inline { .. } => 0, + InnerHuffmanTable::Dynamic { nodes } => nodes.len(), + }; + let bytes = nodes.saturating_mul(size_of::()); + bytes.saturating_add(size_of::()) / size_of::() + } + /// Create a new inline Huffman table from a fixed-size node array. fn from_inline(nodes: [HuffmanNode; INLINE_TABLE_SIZE]) -> Self { Self(Rc::new(InnerHuffmanTable::Inline { nodes })) @@ -219,6 +235,9 @@ impl HuffmanTable { // 5) "Decode each table line as follows:" // d) "If CURRANGELOW ≥ HTHIGH then proceed to step 6." while current_range_low < maximum_value { + if lines.len() >= MAX_TABLE_LINES { + bail!(HuffmanError::InvalidCode); + } // a) "Read HTPS bits. Set PREFLEN[NTEMP] to the value decoded." let prefix_length = reader .read_bits(prefix_length_bits) @@ -448,11 +467,11 @@ impl HuffmanNode { // 4) "Otherwise, if table line I is the lower range table line for this // table, then set: HTVAL = RANGELOW[I] − HTOFFSET" // 5) "Otherwise, set: HTVAL = RANGELOW[I] + HTOFFSET" - // `HTVAL` + // `HTVAL`. Wrapping: a range line's offset can take it past what an `i32` holds. let value = if leaf.is_lower { - leaf.range_low - range_offset + leaf.range_low.wrapping_sub(range_offset) } else { - leaf.range_low + range_offset + leaf.range_low.wrapping_add(range_offset) }; return Ok(Some(value)); diff --git a/vendor/hayro-jbig2/src/integer_decoder.rs b/vendor/hayro-jbig2/src/integer_decoder.rs index 8b7656d296..7f9f7831a2 100644 --- a/vendor/hayro-jbig2/src/integer_decoder.rs +++ b/vendor/hayro-jbig2/src/integer_decoder.rs @@ -70,7 +70,8 @@ impl IntegerDecoder { if s == 0 { Some(v as i32) } else if v > 0 { - Some(-(v as i32)) + // Wrapping: `V` can be 2^31, whose negative is `i32::MIN`, as the cast already reads it. + Some((v as i32).wrapping_neg()) } else { None } diff --git a/vendor/hayro-jbig2/src/lib.rs b/vendor/hayro-jbig2/src/lib.rs index ad77a6dd17..2ee0f8f050 100644 --- a/vendor/hayro-jbig2/src/lib.rs +++ b/vendor/hayro-jbig2/src/lib.rs @@ -75,7 +75,7 @@ pub use error::{ SegmentError, SymbolError, TemplateError, }; -use crate::file::parse_segments_sequential; +use crate::file::{SegmentCount, parse_segments_sequential}; use bitmap::Bitmap; use decode::CombinationOperator; use decode::generic; @@ -120,13 +120,14 @@ impl<'a> Image<'a> { /// Annex D.3. pub fn new_embedded(data: &'a [u8], globals: Option<&'a [u8]>) -> Result { let mut segments = Vec::new(); + let mut count = SegmentCount::default(); if let Some(globals_data) = globals { let mut reader = Reader::new(globals_data); - parse_segments_sequential(&mut reader, &mut segments)?; + parse_segments_sequential(&mut reader, &mut segments, &mut count)?; }; let mut reader = Reader::new(data); - parse_segments_sequential(&mut reader, &mut segments)?; + parse_segments_sequential(&mut reader, &mut segments, &mut count)?; segments.sort_by_key(|seg| seg.header.segment_number); @@ -162,6 +163,7 @@ impl<'a> Image<'a> { if page_info.width == 0 || height == 0 { bail!(FormatError::EmptyPage); } + bitmap::check_page_size(page_info.width, height)?; Ok(Self { segments, @@ -193,11 +195,37 @@ impl<'a> Image<'a> { /// This is useful in case you want to convert multiple JBIG2 images, /// as it allows `hayro-jbig2` to reuse allocations during decoding. pub fn decode_with(&self, decoder: &mut D, ctx: &mut DecoderContext) -> Result<()> { - decode_segments(&self.segments, self.height_from_stripes, ctx)?; + let mut work_left = bitmap::MAX_WORK; + decode_segments(&self.segments, self.height_from_stripes, ctx, &mut work_left)?; emit_bitmap(&ctx.page_bitmap, decoder); Ok(()) } + + /// PdfCraft patch: [`Image::decode`], spending from `work`, what decoding may still cost + /// across a run of images. An image spends what it took, failing or not, starting with its + /// page's pixels, which whoever asked for them will go through, and its segments, so images + /// that decode nothing add up too. On top of that, it takes at most [`bitmap::MAX_WORK`] + /// whatever `work` says. One that `work` can't pay for spends all of it, so a run ends rather + /// than trying every image it has left. Reading the stream comes before this and is charged + /// by [`charge_reading`]. + pub fn decode_within(&self, decoder: &mut D, work: &mut u64) -> Result<()> { + let start = bitmap::decode_work(self.width, self.height).saturating_add(self.segments.len() as u64); + if charge_work(work, start).is_err() { + *work = 0; + bail!(OverflowError::DecodingWork); + } + let allowed = bitmap::MAX_WORK.min(*work); + let mut left = allowed; + let mut ctx = DecoderContext::default(); + let decoded = decode_segments(&self.segments, self.height_from_stripes, &mut ctx, &mut left); + let refused = decoded == Err(OverflowError::DecodingWork.into()); + *work = if refused && allowed < bitmap::MAX_WORK { 0 } else { *work - (allowed - left) }; + decoded?; + emit_bitmap(&ctx.page_bitmap, &mut *decoder); + + Ok(()) + } } fn emit_bitmap(bitmap: &Bitmap, decoder: &mut D) { @@ -257,10 +285,39 @@ fn emit_bitmap(bitmap: &Bitmap, decoder: &mut D) { } } +/// PdfCraft patch: count `work` more decoding work, refusing past what is `left` of +/// [`bitmap::MAX_WORK`] or a run's allowance before what asks for it is decoded. +fn charge_work(left: &mut u64, work: u64) -> Result<()> { + *left = left.checked_sub(work).ok_or(OverflowError::DecodingWork)?; + Ok(()) +} + +/// PdfCraft patch: the steps of reading a byte of a stream or its globals: parsing and sorting +/// segments, and building their tables, measured at about 3 a byte at the worst. +const READ_WORK: u64 = 4; + +/// PdfCraft patch: charge `work`, what a run of images may still cost, for reading `bytes` of a stream +/// and its globals. This comes before anything is parsed, as it counts whether or not they turn out +/// to decode: an image that fails has still cost its reading, and it is read again for each use. What +/// `work` can't pay for spends all of it. +pub fn charge_reading(work: &mut u64, bytes: usize) -> Result<()> { + if charge_work(work, (bytes as u64).saturating_mul(READ_WORK)).is_err() { + *work = 0; + bail!(OverflowError::DecodingWork); + } + Ok(()) +} + +/// PdfCraft patch: the work of a region decoded pixel by pixel. +fn region_work(region: &decode::RegionSegmentInfo) -> u64 { + bitmap::decode_work(region.width, region.height) +} + fn decode_segments( segments: &[segment::Segment<'_>], height_from_stripes: Option, decoder_ctx: &mut DecoderContext, + work_left: &mut u64, ) -> Result<()> { // Find and parse page information segment first. if let Some(page_info) = segments @@ -293,6 +350,7 @@ fn decode_segments( SegmentType::ImmediateGenericRegion | SegmentType::ImmediateLosslessGenericRegion => { let had_unknown_length = seg.header.data_length.is_none(); let header = generic::parse(&mut reader, had_unknown_length)?; + charge_work(work_left, region_work(&header.region_info))?; if page_state.can_decode_directly(page_bitmap, &header.region_info, false) { generic::decode_into(&header, page_bitmap, scratch_buffers)?; @@ -310,25 +368,22 @@ fn decode_segments( SegmentType::IntermediateGenericRegion => { // Intermediate segments cannot have unknown length. let header = generic::parse(&mut reader, false)?; + charge_work(work_left, region_work(&header.region_info))?; let region = generic::decode(&header, scratch_buffers)?; - page_state.store_region(seg.header.segment_number, region.bitmap); + page_state.store_region(seg.header.segment_number, region.bitmap)?; } SegmentType::PatternDictionary => { let header = pattern::parse(&mut reader)?; - let dictionary = pattern::decode(&header, scratch_buffers)?; - page_state.store_pattern_dictionary(seg.header.segment_number, dictionary); + charge_work(work_left, header.work())?; + let dictionary = pattern::decode(&header, scratch_buffers, page_state.retained_room())?; + page_state.store_pattern_dictionary(seg.header.segment_number, dictionary)?; } SegmentType::SymbolDictionary => { // "1) Concatenate all the input symbol dictionaries to form SDINSYMS." // (6.5.5, step 1) // Collect references to avoid cloning; symbols are only cloned if re-exported. - let input_symbols: Vec<&Bitmap> = seg - .header - .referred_to_segments - .iter() - .filter_map(|&num| page_state.get_symbol_dictionary(num)) - .flat_map(|dict| dict.exported_symbols.iter()) - .collect(); + let input_symbols = page_state.referred_symbols(&seg.header.referred_to_segments)?; + charge_work(work_left, input_symbols.len() as u64)?; // Collect Huffman tables from referred table segments. let referred_tables: Vec = seg @@ -354,18 +409,15 @@ fn decode_segments( &referred_tables, &page_state.standard_tables, retained_contexts, + page_state.retained_room(), + work_left, )?; - page_state.store_symbol_dictionary(seg.header.segment_number, dictionary); + page_state.store_symbol_dictionary(seg.header.segment_number, dictionary)?; } SegmentType::ImmediateTextRegion | SegmentType::ImmediateLosslessTextRegion => { // Collect symbols from referred symbol dictionaries (SBSYMS). - let symbols: Vec<&Bitmap> = seg - .header - .referred_to_segments - .iter() - .filter_map(|&num| page_state.get_symbol_dictionary(num)) - .flat_map(|dict| dict.exported_symbols.iter()) - .collect(); + let symbols = page_state.referred_symbols(&seg.header.referred_to_segments)?; + charge_work(work_left, symbols.len() as u64)?; // Collect Huffman tables from referred table segments. // "These user-supplied Huffman decoding tables may be supplied either @@ -379,6 +431,11 @@ fn decode_segments( .collect(); let header = text::parse(&mut reader, symbols.len() as u32)?; + charge_work(work_left, bitmap::copy_work(header.region_info.width, header.region_info.height))?; + if header.flags.use_huffman { + // Its symbol ID table has a line for each symbol. + charge_work(work_left, (symbols.len() as u64).saturating_mul(bitmap::HUFFMAN_LINE_WORK))?; + } if page_state.can_decode_directly( page_bitmap, @@ -392,6 +449,7 @@ fn decode_segments( &page_state.standard_tables, page_bitmap, scratch_buffers, + work_left, )?; } else { let region = text::decode( @@ -400,6 +458,7 @@ fn decode_segments( &referred_tables, &page_state.standard_tables, scratch_buffers, + work_left, )?; page_bitmap.combine( ®ion.bitmap, @@ -412,13 +471,8 @@ fn decode_segments( } SegmentType::IntermediateTextRegion => { // Collect symbols from referred symbol dictionaries (SBSYMS). - let symbols: Vec<&Bitmap> = seg - .header - .referred_to_segments - .iter() - .filter_map(|&num| page_state.get_symbol_dictionary(num)) - .flat_map(|dict| dict.exported_symbols.iter()) - .collect(); + let symbols = page_state.referred_symbols(&seg.header.referred_to_segments)?; + charge_work(work_left, symbols.len() as u64)?; // Collect Huffman tables from referred table segments. let referred_tables: Vec = seg @@ -430,14 +484,20 @@ fn decode_segments( .collect(); let header = text::parse(&mut reader, symbols.len() as u32)?; + charge_work(work_left, bitmap::copy_work(header.region_info.width, header.region_info.height))?; + if header.flags.use_huffman { + // Its symbol ID table has a line for each symbol. + charge_work(work_left, (symbols.len() as u64).saturating_mul(bitmap::HUFFMAN_LINE_WORK))?; + } let region = text::decode( &header, &symbols, &referred_tables, &page_state.standard_tables, scratch_buffers, + work_left, )?; - page_state.store_region(seg.header.segment_number, region.bitmap); + page_state.store_region(seg.header.segment_number, region.bitmap)?; } SegmentType::ImmediateHalftoneRegion | SegmentType::ImmediateLosslessHalftoneRegion => { let pattern_dict = seg @@ -448,6 +508,7 @@ fn decode_segments( .ok_or(SegmentError::MissingPatternDictionary)?; let header = halftone::parse(&mut reader)?; + charge_work(work_left, halftone::work(&header, pattern_dict))?; if page_state.can_decode_directly( page_bitmap, @@ -475,8 +536,9 @@ fn decode_segments( .ok_or(SegmentError::MissingPatternDictionary)?; let header = halftone::parse(&mut reader)?; + charge_work(work_left, halftone::work(&header, pattern_dict))?; let region = halftone::decode(&header, pattern_dict, scratch_buffers)?; - page_state.store_region(seg.header.segment_number, region.bitmap); + page_state.store_region(seg.header.segment_number, region.bitmap)?; } SegmentType::IntermediateGenericRefinementRegion => { // Same logic as immediate refinement, but store result instead of combining. @@ -488,8 +550,9 @@ fn decode_segments( .unwrap_or(page_bitmap); let header = generic_refinement::parse(&mut reader)?; + charge_work(work_left, region_work(&header.region_info))?; let region = generic_refinement::decode(&header, reference, scratch_buffers)?; - page_state.store_region(seg.header.segment_number, region.bitmap); + page_state.store_region(seg.header.segment_number, region.bitmap)?; } SegmentType::ImmediateGenericRefinementRegion | SegmentType::ImmediateLosslessGenericRefinementRegion => { @@ -505,6 +568,7 @@ fn decode_segments( .and_then(|&num| page_state.get_referred_segment(num)); let header = generic_refinement::parse(&mut reader)?; + charge_work(work_left, region_work(&header.region_info))?; if let Some(referred_segment) = referred_segment && page_state.can_decode_directly(page_bitmap, &header.region_info, false) @@ -532,7 +596,7 @@ fn decode_segments( // "This segment contains data which defines one or more user-supplied // Huffman coding tables." (7.4.13) let table = HuffmanTable::read_custom(&mut reader)?; - page_state.store_huffman_table(seg.header.segment_number, table); + page_state.store_huffman_table(seg.header.segment_number, table)?; } SegmentType::EndOfPage | SegmentType::EndOfFile => { break; @@ -545,6 +609,23 @@ fn decode_segments( Ok(()) } +/// PdfCraft patch: the words a stored item costs in its list, at least, apart from what it holds. +const ENTRY_WORDS: u64 = 32; + +/// PdfCraft patch: the most words a page's segments may hold for later ones, 64 MiB: two square +/// bitmaps of 16,384 a side (`bitmap::MAX_PIXELS`). Each stored item is bounded on its own, but without a +/// bound on all of them a stream of a few dozen bytes a segment held 33 MB each (a 16,384-sample +/// square region of typical rows), and a few kilobytes could exhaust memory. A segment being +/// decoded takes more for a while, within its own bounds: the page bitmap isn't counted, nor is +/// what a region or a grid uses until it is stored or freed. +const MAX_RETAINED_WORDS: u64 = + 2 * (bitmap::MAX_PIXELS / bitmap::WORD_BITS as u64 + bitmap::BITMAP_WORDS as u64 + ENTRY_WORDS); + +/// PdfCraft patch: the most symbols one segment may refer to, 2^20, far beyond the 65,535 a +/// dictionary may export. References may repeat, so each names the same symbols again: without +/// this, 65,535 references to one dictionary in a 78 KB stream collected 17 GB of pointers. +const MAX_REFERRED_SYMBOLS: u64 = 1 << 20; + /// Page-level decoding state for a JBIG2 page. #[derive(Default)] pub(crate) struct PageState { @@ -561,6 +642,9 @@ pub(crate) struct PageState { /// Decoded Huffman tables from table segments, stored as (`segment_number`, table) pairs. /// "Tables – see 7.4.13." (type 53) pub(crate) huffman_tables: Vec<(u32, HuffmanTable)>, + /// PdfCraft patch: the words the regions, dictionaries and tables above hold, at most + /// [`MAX_RETAINED_WORDS`]. + pub(crate) retained_words: u64, /// Standard Huffman tables (`TABLE_A` through `TABLE_O`). pub(crate) standard_tables: StandardHuffmanTables, } @@ -573,6 +657,7 @@ impl PageState { self.pattern_dictionaries.clear(); self.symbol_dictionaries.clear(); self.huffman_tables.clear(); + self.retained_words = 0; // Standard tables are lazily built and reused across images. } @@ -610,9 +695,26 @@ impl PageState { } } + /// PdfCraft patch: the words the page may still hold for later segments. + fn retained_room(&self) -> u64 { + MAX_RETAINED_WORDS.saturating_sub(self.retained_words) + } + + /// PdfCraft patch: count a stored item holding `words` for later segments, refusing past + /// [`MAX_RETAINED_WORDS`]. + fn retain(&mut self, words: usize) -> Result<()> { + self.retained_words = self.retained_words.saturating_add(words as u64).saturating_add(ENTRY_WORDS); + if self.retained_words > MAX_RETAINED_WORDS { + bail!(OverflowError::RetainedBitmaps); + } + Ok(()) + } + /// Store a decoded region for later reference. - fn store_region(&mut self, segment_number: u32, region: Bitmap) { + fn store_region(&mut self, segment_number: u32, region: Bitmap) -> Result<()> { + self.retain(region.words())?; self.referred_segments.push((segment_number, region)); + Ok(()) } /// Look up a referred segment by number. @@ -624,8 +726,10 @@ impl PageState { } /// Store a decoded pattern dictionary for later reference. - fn store_pattern_dictionary(&mut self, segment_number: u32, dictionary: PatternDictionary) { + fn store_pattern_dictionary(&mut self, segment_number: u32, dictionary: PatternDictionary) -> Result<()> { + self.retain(dictionary.words())?; self.pattern_dictionaries.push((segment_number, dictionary)); + Ok(()) } /// Look up a pattern dictionary by segment number. @@ -637,8 +741,23 @@ impl PageState { } /// Store a decoded symbol dictionary for later reference. - fn store_symbol_dictionary(&mut self, segment_number: u32, dictionary: SymbolDictionary) { + fn store_symbol_dictionary(&mut self, segment_number: u32, dictionary: SymbolDictionary) -> Result<()> { + self.retain(dictionary.words())?; self.symbol_dictionaries.push((segment_number, dictionary)); + Ok(()) + } + + /// PdfCraft patch: the symbols of the dictionaries in `segments`, in order, refusing past + /// [`MAX_REFERRED_SYMBOLS`] before anything is collected. + fn referred_symbols(&self, segments: &[u32]) -> Result> { + let dictionaries = || segments.iter().filter_map(|&num| self.get_symbol_dictionary(num)); + let count = dictionaries() + .map(|dictionary| dictionary.exported_symbols.len() as u64) + .fold(0, u64::saturating_add); + if count > MAX_REFERRED_SYMBOLS { + bail!(OverflowError::SymbolCount); + } + Ok(dictionaries().flat_map(|dictionary| dictionary.exported_symbols.iter()).collect()) } /// Look up a symbol dictionary by segment number. @@ -650,8 +769,10 @@ impl PageState { } /// Store a decoded Huffman table for later reference. - fn store_huffman_table(&mut self, segment_number: u32, table: HuffmanTable) { + fn store_huffman_table(&mut self, segment_number: u32, table: HuffmanTable) -> Result<()> { + self.retain(table.words())?; self.huffman_tables.push((segment_number, table)); + Ok(()) } /// Look up a Huffman table by segment number. @@ -690,3 +811,51 @@ fn init_page( Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + /// A page past the bounds is refused when the stream is opened, since callers size their + /// output from it before decoding starts. 16,384 squared is exactly the bound on pixels. + #[test] + fn a_page_past_the_bounds_is_refused_when_opened() { + let stream = |width: u32, height: u32| { + let mut stream = vec![0, 0, 0, 0, 48, 0, 1, 0, 0, 0, 19]; + stream.extend_from_slice(&width.to_be_bytes()); + stream.extend_from_slice(&height.to_be_bytes()); + stream.extend_from_slice(&[0; 11]); // resolution, flags, striping + stream + }; + assert!(Image::new_embedded(&stream(16384, 16384), None).is_ok()); + assert!(Image::new_embedded(&stream(16385, 16384), None).is_err()); + assert!(Image::new_embedded(&stream(65536, 1), None).is_err(), "too wide, though few pixels"); + assert!(Image::new_embedded(&stream(1 << 20, 1 << 20), None).is_err()); + } + + /// A stream and its globals are one stream of segments, so they have one count. + #[test] + fn the_segments_of_a_stream_and_its_globals_count_together() { + let segment: &[u8] = &[0, 0, 0, 0, 62, 0, 1, 0, 0, 0, 0]; // an empty extension + let half = segment.repeat(1 << 19); + let more = segment.repeat((1 << 19) + 1); + let refused = |data: &[u8]| { + let error = Image::new_embedded(data, Some(&half)).err().map(|e| e.to_string()); + error.as_deref() == Some("too many segments or references in a stream") + }; + assert!(!refused(&half), "2^20 segments are allowed"); + assert!(refused(&more), "one more isn't"); + } + + /// A dictionary being decoded has what the page may still hold, not all of it: refused only + /// once it is stored, its copies would already have been made. + #[test] + fn the_room_left_shrinks_as_segments_are_stored() { + let mut page = PageState::default(); + assert_eq!(page.retained_room(), MAX_RETAINED_WORDS); + page.retain(1000).unwrap(); + assert_eq!(page.retained_room(), MAX_RETAINED_WORDS - 1000 - ENTRY_WORDS); + page.retained_words = MAX_RETAINED_WORDS; + assert_eq!(page.retained_room(), 0); + } +} diff --git a/vendor/hayro-syntax/src/content/mod.rs b/vendor/hayro-syntax/src/content/mod.rs index e37044c5d7..1d71d73d2e 100644 --- a/vendor/hayro-syntax/src/content/mod.rs +++ b/vendor/hayro-syntax/src/content/mod.rs @@ -117,6 +117,15 @@ const MAX_EI_LOOKAHEAD_STEPS: u32 = 256; pub trait ContentTokenizer<'a> { /// Return the next instruction, or `None` after the content stream ends or becomes invalid. fn next_instruction<'b>(&'b mut self) -> Option>; + + /// PdfCraft patch: whether this tokenizer is only judging whether bytes read as content, as + /// the look-ahead past a candidate `EI` does. Such a pass must not recover from a malformed + /// token: its whole purpose is to find out whether the bytes are content, and answering + /// "yes, once the bad parts are skipped" of an inline image's data would cut the image at a + /// false `EI` (#3636 review). + fn judging_only(&self) -> bool { + false + } } /// An iterator over operators in the PDF content streams, providing raw access to the @@ -198,14 +207,28 @@ impl<'a> UntypedIter<'a> { if self.undecided() { return None; } - self.stack.push(object)?; + self.stack.push(object); } else if self.reader.read_without_context::>().is_some() { if self.undecided() { return None; } self.stack.clear(); + } else if self.undecided() { + return None; + } else if !self.lookahead && self.reader.peek_byte() == Some(b'/') && self.skip_bad_token() { + // PdfCraft patch: a name the lexer cannot read -- a malformed `#` escape, as + // in `/C#2 gs` -- used to end the stream, losing the rest of the page. Step + // over it and carry on, as the edit reader and other readers do (#3568). + // + // Only a name. An operand that is well formed but refused by a resource cap + // (a dictionary nested past the limit) still ends the stream, which is what + // `dictionaries_nested_without_keys_are_read_up_to_the_nesting_cap` in + // pdfcraft's renderer asserts: reading on there would draw a page whose + // content was deliberately not parsed. + warn!("skipped an unreadable name in content stream"); + self.stack.clear(); + continue; } else { - self.undecided(); return None; } } else { @@ -218,6 +241,13 @@ impl<'a> UntypedIter<'a> { warn!("failed to read operator in content stream"); + // PdfCraft patch: a stray delimiter (`)`, say) ended the stream here, so + // the rest of the page was lost. Step over it and carry on (#3568). + if !self.lookahead && self.skip_bad_token() { + self.stack.clear(); + continue; + } + self.reader.jump_to_end(); return None; } @@ -263,7 +293,7 @@ impl<'a> UntypedIter<'a> { UnfilteredEnd::Found(end_data, end_after_ei) => { let image_data = stream_data.get(..end_data)?; let stream = Stream::new(image_data, dict.clone()); - self.stack.push(Object::Stream(stream))?; + self.stack.push(Object::Stream(stream)); self.reader.read_bytes(end_after_ei)?; self.reader.skip_white_spaces(); accepted = true; @@ -425,7 +455,7 @@ impl<'a> UntypedIter<'a> { return None; } - self.stack.push(Object::Stream(stream))?; + self.stack.push(Object::Stream(stream)); self.reader.read_bytes(2)?; self.reader.skip_white_spaces(); @@ -484,6 +514,36 @@ impl<'a> UntypedIter<'a> { open_comment.unwrap_or(self.reader.offset()) } + /// PdfCraft patch: step over one token the lexer could not read, so the rest of the stream is + /// still read instead of the page ending there. A delimiter stands alone; anything else runs + /// to the next delimiter or space. At least one byte is always consumed when this returns + /// true, so the loop in `next` cannot spin (#3568). + fn skip_bad_token(&mut self) -> bool { + let before = self.reader.offset(); + match self.reader.peek_byte() { + None => return false, + // Never step over a good token: called where the blanks after the last one have not + // been skipped yet, there is nothing bad here to skip. Skipping ` BT` cost the page + // its second text object while this was being written. + Some(b) if is_white_space_character(b) || b == b'%' => {} + // A name reads to the next delimiter, malformed escapes included. + Some(b'/') => { + let _ = skip_name_like(&mut self.reader, true); + } + // A token of regular characters runs to the next delimiter or space. + Some(b) if is_regular_character(b) => { + self.reader.forward_while(is_regular_character); + } + // A delimiter stands alone. Consuming what follows it would eat the next operator: + // `)BT` cost the page its second text object while this was being written + // (#3636 review). + Some(_) => self.reader.forward(), + } + // Land on the next token, as the end of the loop in `next` does. + self.reader.skip_white_spaces_and_comments(); + self.reader.offset() > before + } + /// PdfCraft patch: whether the instruction being read ran into the end of a window, so it /// cannot be decided yet. Records that more data is needed. fn undecided(&mut self) -> bool { @@ -499,6 +559,10 @@ impl<'a> ContentTokenizer<'a> for UntypedIter<'a> { fn next_instruction<'b>(&'b mut self) -> Option> { self.next() } + + fn judging_only(&self) -> bool { + self.lookahead + } } @@ -557,6 +621,8 @@ where /// Return the next typed instruction. #[allow(clippy::should_implement_trait)] pub fn next(&mut self) -> Option> { + // Read before the instruction borrows the tokenizer. + let judging = self.untyped.judging_only(); let op = self.untyped.next_instruction()?; // TODO: Explore whether dispatching can be made more efficient. match TypedInstruction::dispatch(&op) { @@ -565,22 +631,22 @@ where // operator was used, let's abort completely, otherwise we might end up drawing random stuff. // However, for other operators it could be worth it to just skip it but keep attempting // to read other content operators. + // PdfCraft patch: a path operator whose operands do not fit it contributes nothing to + // the path, so skipping it leaves the path as it was -- the same state the abort + // below used to leave it in, except that the rest of the page is still read. Ending + // the stream here lost every later operator, and with it the rest of the page, its + // text and anything a redaction search would have matched (#3568). None => { - if [ - &b"m"[..], - &b"l"[..], - &b"c"[..], - &b"v"[..], - &b"y"[..], - &b"h"[..], - &b"re"[..], - ] - .contains(&op.operator.0.deref()) + // A pass that is only judging whether bytes read as content must still stop at a + // path operator it cannot dispatch: counting it as a step would let an inline + // image's data pass for a content stream and cut the image at a false `EI` + // (#3636 review). + if judging + && [&b"m"[..], &b"l"[..], &b"c"[..], &b"v"[..], &b"y"[..], &b"h"[..], &b"re"[..]].contains(&op.operator.0.deref()) { - None - } else { - Some(TypedInstruction::Fallback(op.operator)) + return None; } + Some(TypedInstruction::Fallback(op.operator)) } } } @@ -631,11 +697,16 @@ impl<'a> Stack<'a> { } /// PdfCraft patch (28): push an operand, dropping the oldest once `MAX_OPERANDS` are held - /// (see `MAX_OPERANDS`). Never fails: it used to, and the failure ended the content stream. - fn push(&mut self, operand: Object<'a>) -> Option<()> { + /// (see `MAX_OPERANDS`). It cannot fail: it used to, and the failure ended the content stream. + /// + /// The operands kept are the newest, so the ones nearest the operator -- which are its own. + /// No operator takes anywhere near `MAX_OPERANDS` of them (`scn` takes one per colourant, and + /// ISO 32000 allows a DeviceN space 32), so an instruction preceded by stray operands is + /// applied to its own and the strays fall off the back. + fn push(&mut self, operand: Object<'a>) { if self.data.len() < MAX_OPERANDS { self.data.push(operand); - return Some(()); + return; } self.dropped = true; @@ -643,7 +714,6 @@ impl<'a> Stack<'a> { *slot = operand; } self.oldest = (self.oldest + 1) % MAX_OPERANDS; - Some(()) } /// PdfCraft patch (28): put the operands back in the order they were read, oldest first, diff --git a/vendor/hayro-syntax/src/data.rs b/vendor/hayro-syntax/src/data.rs index 9d5e2030f8..90c44f89c9 100644 --- a/vendor/hayro-syntax/src/data.rs +++ b/vendor/hayro-syntax/src/data.rs @@ -1,5 +1,4 @@ use crate::object::ObjectIdentifier; -use crate::object::Stream; use crate::reader::ReaderContext; use crate::sync::HashMap; use crate::sync::{Arc, Mutex, MutexExt}; @@ -92,6 +91,12 @@ impl Data { &self.data } + /// PdfCraft patch: (hayro-syntax (33)) enter the gate for what an object stream's index depends + /// on, once its data is read and [`Self::get_with`] has let go of it. + pub(crate) fn enter_loading(&self, id: ObjectIdentifier) -> Option> { + self.loading.enter(id) + } + /// Get access to the data of a decoded object stream. pub(crate) fn get_with(&self, id: ObjectIdentifier, ctx: &ReaderContext<'_>) -> Option<&[u8]> { // PdfCraft patch: (#307, see vendor/README.md hayro-syntax (10)) register exactly once: @@ -110,7 +115,8 @@ impl Data { let _loading = self.loading.enter(id)?; self.decoded .get_or_init(idx, || { - let stream = ctx.xref().get_with::>(id, ctx)?; + // PdfCraft patch: (hayro-syntax (33)) only ever an object stream. + let stream = ctx.xref().object_stream(id, ctx)?; stream.decoded().ok().map(Cow::into_owned) })? .as_deref() @@ -244,7 +250,7 @@ mod tests { } } -struct LoadGuard<'a>(&'a LoadGate); +pub(crate) struct LoadGuard<'a>(&'a LoadGate); impl Drop for LoadGuard<'_> { fn drop(&mut self) { diff --git a/vendor/hayro-syntax/src/filter/jbig2.rs b/vendor/hayro-syntax/src/filter/jbig2.rs index d87253f536..33a7bc4dd8 100644 --- a/vendor/hayro-syntax/src/filter/jbig2.rs +++ b/vendor/hayro-syntax/src/filter/jbig2.rs @@ -1,4 +1,5 @@ use crate::bit_reader::BitWriter; +use crate::filter::Filter; use crate::object::Dict; use crate::object::Stream; use crate::object::dict::keys::JBIG2_GLOBALS; @@ -8,6 +9,65 @@ use alloc::vec; use alloc::vec::Vec; use core::iter; +/// PdfCraft patch (31): the steps of decoding a page's JBIG2 images may take between them, as many +/// as one may. Each has its own limit, which doesn't bound a page of many made to reach it. That +/// is about 6 s to 17 s, inside a render's watchdog; an image of the largest size costs a quarter of +/// it to decode and a quarter for its page, and ordinary ones are far below that. +pub const MAX_PAGE_JBIG2_WORK: u64 = 1 << 30; + +#[cfg(feature = "std")] +std::thread_local! { + // Unbounded until a page says otherwise: what isn't drawing a page has no page to run out on. + static PAGE_WORK_LEFT: core::cell::Cell = const { core::cell::Cell::new(u64::MAX) }; +} + +/// PdfCraft patch (31): what the JBIG2 images decoded next on this thread may take, for a new page. +#[cfg(feature = "std")] +pub fn set_page_jbig2_work(work: u64) { + PAGE_WORK_LEFT.with(|left| left.set(work)); +} + +/// Pays for reading `bytes` of a stream and its globals out of what the page has left, or `None`. +/// An image that doesn't decode isn't kept, so a page drawing it again and again reads it again. +#[cfg(feature = "std")] +fn charge_page_reading(bytes: usize) -> Option<()> { + let mut left = PAGE_WORK_LEFT.with(core::cell::Cell::get); + let charged = hayro_jbig2::charge_reading(&mut left, bytes); + PAGE_WORK_LEFT.with(|cell| cell.set(left)); + charged.ok() +} + +#[cfg(not(feature = "std"))] +fn charge_page_reading(_: usize) -> Option<()> { + Some(()) +} + +/// `image` decoded through `decoder`, spending what the page has left, if there is anything. +#[cfg(feature = "std")] +fn decode_for_page(image: &hayro_jbig2::Image<'_>, decoder: &mut D) -> Option<()> { + let mut left = PAGE_WORK_LEFT.with(core::cell::Cell::get); + let decoded = image.decode_within(decoder, &mut left); + PAGE_WORK_LEFT.with(|cell| cell.set(left)); + decoded.ok() +} + +#[cfg(not(feature = "std"))] +fn decode_for_page(image: &hayro_jbig2::Image<'_>, decoder: &mut D) -> Option<()> { + image.decode(decoder).ok() +} + +/// PdfCraft patch (31): whether the page has nothing left for another image, which then isn't read +/// at all. +#[cfg(feature = "std")] +pub fn page_jbig2_work_spent() -> bool { + PAGE_WORK_LEFT.with(core::cell::Cell::get) == 0 +} + +#[cfg(not(feature = "std"))] +pub fn page_jbig2_work_spent() -> bool { + false +} + /// Decode JBIG2 data from a PDF stream. /// /// The `params` dictionary may contain a `JBIG2Globals` entry pointing to @@ -17,10 +77,20 @@ pub(crate) fn decode( params: &Dict<'_>, image_params: &ImageDecodeParams, ) -> Option> { + if page_jbig2_work_spent() { + return None; + } + + // PdfCraft patch: globals are raw segments, so JBIG2 inside them means nothing, and decoding + // it reads its own globals in turn: a chain of 16,000 such streams overflowed the stack, which + // aborts the process. Such a stream reads as globals that can't be decoded. let globals = params .get::>(JBIG2_GLOBALS) + .filter(|g| !g.filters().iter().any(|f| matches!(f, Filter::Jbig2Decode))) .and_then(|g| g.decoded().ok()); + charge_page_reading(data.len().saturating_add(globals.as_deref().map_or(0, <[u8]>::len)))?; + let image = hayro_jbig2::Image::new_embedded(data, globals.as_deref()).ok()?; // Whenever possible (if we don't have an indexed color space), we convert @@ -55,7 +125,7 @@ pub(crate) fn decode( let writer = BitWriter::new(&mut packed, 1)?; let mut decoder = BitWriterDecoder { writer }; - image.decode(&mut decoder).ok()?; + decode_for_page(&image, &mut decoder)?; (packed, 1) } else { @@ -78,7 +148,7 @@ pub(crate) fn decode( } let mut decoder = Luma8Decoder { output: Vec::new() }; - image.decode(&mut decoder).ok()?; + decode_for_page(&image, &mut decoder)?; (decoder.output, 8) }; diff --git a/vendor/hayro-syntax/src/filter/mod.rs b/vendor/hayro-syntax/src/filter/mod.rs index 06bda29487..e81a37df98 100644 --- a/vendor/hayro-syntax/src/filter/mod.rs +++ b/vendor/hayro-syntax/src/filter/mod.rs @@ -9,6 +9,10 @@ mod dct; #[cfg(feature = "images")] mod jbig2; #[cfg(feature = "images")] +pub use jbig2::{MAX_PAGE_JBIG2_WORK, page_jbig2_work_spent}; +#[cfg(all(feature = "images", feature = "std"))] +pub use jbig2::set_page_jbig2_work; +#[cfg(feature = "images")] mod jpx; mod lzw_flate; mod run_length; diff --git a/vendor/hayro-syntax/src/object/array.rs b/vendor/hayro-syntax/src/object/array.rs index d39230f7db..fb136e867a 100644 --- a/vendor/hayro-syntax/src/object/array.rs +++ b/vendor/hayro-syntax/src/object/array.rs @@ -191,6 +191,12 @@ impl<'a> FlexArrayIter<'a> { self.reader.skip_white_spaces_and_comments(); if !self.reader.at_end() { + // PdfCraft patch (32): its items each follow a reference. Read as one that might be, a + // reference at the start was taken for the whole group, which it can't be, and lost. + if T::GROUP { + return self.reader.read_with_context::(&self.ctx); + } + return match self.reader.read_with_context::>(&self.ctx)? { MaybeRef::Ref(r) => self.ctx.xref().get_with::(r.into(), &self.ctx), MaybeRef::NotRef(i) => Some(i), diff --git a/vendor/hayro-syntax/src/object/mod.rs b/vendor/hayro-syntax/src/object/mod.rs index d692db8b38..6ad97b64e4 100644 --- a/vendor/hayro-syntax/src/object/mod.rs +++ b/vendor/hayro-syntax/src/object/mod.rs @@ -32,7 +32,11 @@ pub mod dict; pub mod stream; /// A trait for PDF objects. -pub(crate) trait ObjectLike<'a>: TryFrom> + Readable<'a> + Debug + Clone {} +pub(crate) trait ObjectLike<'a>: TryFrom> + Readable<'a> + Debug + Clone { + /// PdfCraft patch (32): whether this is several items of an array, read one by one (a tuple). + /// Such a thing can't itself be given by reference, so one at its start is its first item's. + const GROUP: bool = false; +} /// A trait for PDF objects that can be borrowed directly from an [`Object`]. pub(crate) trait ObjectRefLike<'a>: Sized { diff --git a/vendor/hayro-syntax/src/object/ref.rs b/vendor/hayro-syntax/src/object/ref.rs index 71951f4d82..3d3cbffbe1 100644 --- a/vendor/hayro-syntax/src/object/ref.rs +++ b/vendor/hayro-syntax/src/object/ref.rs @@ -39,10 +39,11 @@ impl From for ObjRef { impl Skippable for ObjRef { fn skip(r: &mut Reader<'_>, _: bool) -> Option<()> { + // PdfCraft patch: (hayro-syntax (33)) a comment counts as white space, here too. r.skip::(false)?; - r.skip_white_spaces(); + r.skip_white_spaces_and_comments(); r.skip::(false)?; - r.skip_white_spaces(); + r.skip_white_spaces_and_comments(); r.forward_tag(b"R")?; Some(()) @@ -52,9 +53,9 @@ impl Skippable for ObjRef { impl Readable<'_> for ObjRef { fn read(r: &mut Reader<'_>, _: &ReaderContext<'_>) -> Option { let obj_ref = r.read_without_context::()?; - r.skip_white_spaces(); + r.skip_white_spaces_and_comments(); let gen_num = r.read_without_context::()?; - r.skip_white_spaces(); + r.skip_white_spaces_and_comments(); r.forward_tag(b"R")?; Some(Self::new(obj_ref, gen_num)) diff --git a/vendor/hayro-syntax/src/object/tuple.rs b/vendor/hayro-syntax/src/object/tuple.rs index 4a7190e916..c76c1b408c 100644 --- a/vendor/hayro-syntax/src/object/tuple.rs +++ b/vendor/hayro-syntax/src/object/tuple.rs @@ -39,6 +39,7 @@ where T: ObjectLike<'a> + Debug + Clone, U: ObjectLike<'a> + Debug + Clone, { + const GROUP: bool = true; } impl<'a, T, U, V> Readable<'a> for (T, U, V) @@ -78,6 +79,7 @@ where U: ObjectLike<'a> + Debug + Clone, V: ObjectLike<'a> + Debug + Clone, { + const GROUP: bool = true; } impl<'a, T, U, V, W> Readable<'a> for (T, U, V, W) @@ -122,6 +124,7 @@ where V: ObjectLike<'a> + Debug + Clone, W: ObjectLike<'a> + Debug + Clone, { + const GROUP: bool = true; } impl<'a, T, U, V, W, X> Readable<'a> for (T, U, V, W, X) @@ -171,4 +174,5 @@ where W: ObjectLike<'a> + Debug + Clone, X: ObjectLike<'a> + Debug + Clone, { + const GROUP: bool = true; } diff --git a/vendor/hayro-syntax/src/xref.rs b/vendor/hayro-syntax/src/xref.rs index 745af95556..d3ab7d324f 100644 --- a/vendor/hayro-syntax/src/xref.rs +++ b/vendor/hayro-syntax/src/xref.rs @@ -4,6 +4,7 @@ use crate::crypto::{DecryptionError, DecryptionTarget, Decryptor, get}; use crate::data::Data; use crate::metadata::Metadata; use crate::object::Name; +use crate::object::Number; use crate::object::ObjectIdentifier; use crate::object::Stream; use crate::object::dict::keys::{ @@ -12,7 +13,7 @@ use crate::object::dict::keys::{ }; use crate::object::dict::{DictCache, probe_dict}; use crate::object::indirect::IndirectObject; -use crate::object::{Array, MaybeRef}; +use crate::object::{Array, MaybeRef, ObjRef}; use crate::object::{DateTime, Dict}; use crate::object::{Object, ObjectLike}; use crate::pdf::PdfVersion; @@ -31,6 +32,17 @@ use core::ops::Deref; pub(crate) const XREF_ENTRY_LEN: usize = 20; +/// PdfCraft patch: (hayro-syntax (33)) how many references a read follows through objects that hold +/// nothing else. Past it, as any cycle goes, the object reads as a missing one. +const MAX_REFERENCE_HOPS: usize = 32; + +/// PdfCraft patch: (hayro-syntax (33)) what [`XRef::fetch`] found at an object's place. +enum Fetched { + Object(T), + /// All the object holds is a reference to this one. + Reference(ObjectIdentifier), +} + #[derive(Debug, Copy, Clone)] pub(crate) enum XRefError { Unknown, @@ -69,6 +81,65 @@ pub(crate) fn fallback(data: PdfData, password: &[u8]) -> Option { } } +/// PdfCraft patch: an `N G obj` header for the repair scan, taking a generation number that +/// cannot be read as 0. +/// +/// A generation number is at most 65,535 (ISO 32000-2 §7.5.4), so `3 18446744073709551616 obj` is +/// malformed and [`ObjectIdentifier::read`] is right to refuse it everywhere else. This scan runs +/// only when a file's cross-reference table is unusable and its objects are being recovered by +/// reading the file, where refusing the header drops the object and whatever it holds (#5580). +/// Before #427 the digits wrapped, and 2^64 wrapped to exactly 0, which is the only reason such a +/// file used to open. +/// +/// Only here: reading numbers anywhere else is unchanged. +fn read_object_header<'a>(r: &mut Reader<'a>, ctx: &ReaderContext<'a>) -> Option { + let mut strict = r.clone(); + if let Some(obj_id) = strict.read::(ctx) { + *r = strict; + return Some(obj_id); + } + // On a clone as well, so a header that isn't one leaves `r` where it was for the caller. + let mut lenient = r.clone(); + let obj_number = lenient.read_without_context::()?; + lenient.skip_white_spaces_and_comments(); + // Any number at all in the generation position, including one no generation may be. + lenient.skip::(false)?; + lenient.skip_white_spaces_and_comments(); + lenient.forward_tag(b"obj")?; + *r = lenient; + Some(ObjectIdentifier::new(obj_number, 0)) +} + +/// PdfCraft patch: the body of the object at `offset` whose `N G obj` header cannot be read +/// strictly, for a table rebuilt by scanning the file (#5580). The steps are +/// `IndirectObject::read`'s, with [`read_object_header`] in place of the strict identifier. +fn read_repaired_body<'a, T>( + data: &'a [u8], + offset: usize, + id: ObjectIdentifier, + ctx: &ReaderContext<'a>, +) -> Option +where + T: ObjectLike<'a>, +{ + let mut r = Reader::new(data); + r.jump(offset); + // The number is what identifies the object; the generation is what could not be read. + if read_object_header(&mut r, ctx)?.obj_number != id.obj_number { + return None; + } + let mut ctx = ctx.clone(); + if ctx.parent_chain_contains(&id) { + warn!("cycle detected in indirect object: {id:?}"); + + return None; + } + ctx.set_obj_number(id); + ctx.parent_chain_push(id); + r.skip_white_spaces_and_comments(); + r.read_with_context::(&ctx) +} + fn fallback_xref_map<'a>(data: &'a PdfData, password: &[u8]) -> (XrefMap, Option>) { fallback_xref_map_inner(data, ReaderContext::dummy(), true, password) } @@ -80,6 +151,10 @@ fn fallback_xref_map_inner<'a>( password: &[u8], ) -> (XrefMap, Option>) { let mut xref_map = FxHashMap::default(); + // PdfCraft patch: the generation-0 keys below that stand in for a header with another + // generation, so a later header can replace one while a real `N 0 obj` is never displaced + // (noted in review of #5580). + let mut aliases: rustc_hash::FxHashSet = rustc_hash::FxHashSet::default(); let mut trailer_dicts = vec![]; let mut root_ref = None; @@ -94,12 +169,32 @@ fn fallback_xref_map_inner<'a>( // First try to check if we have an object identifier. if r.peek_byte().is_some_and(|b: u8| b.is_ascii_digit()) { - if let Some(obj_id) = r.read::(&dummy_ctx) { + if let Some(obj_id) = read_object_header(&mut r, &dummy_ctx) { let mut cloned = r.clone(); // Check that the object following it is actually valid before inserting it. cloned.skip_white_spaces_and_comments(); if cloned.skip::>(false).is_some() { xref_map.insert(obj_id, EntryType::Normal(cur_pos)); + // PdfCraft patch: also reachable by object number alone, so a reference that + // names a different generation than the header still finds it. A rebuilt + // table has no revision history to tell two generations apart, and a file + // that needs rebuilding references everything as `N 0 R` whatever its headers + // say: `poppler-85140-0.pdf` has one page whose header reads + // `3 18446744073709551616 obj` with a page tree pointing at `3 0 R`, so the + // page was lost and the file did not open at all (#5580). `or_insert`, so a + // real `N 0 obj` is never displaced by this alias. + let by_number = ObjectIdentifier::new(obj_id.obj_number, 0); + if obj_id.gen_number == 0 { + // A real `N 0 obj`: it owns the key from here on, and the last one wins + // as every other entry does. + aliases.remove(&by_number); + } else if !xref_map.contains_key(&by_number) || aliases.contains(&by_number) { + // Stand in for it, and let a later header for the same number replace the + // stand-in -- the exact key moves on, so an alias left behind would send + // `N 0 R` to a body the exact key no longer names (noted in review). + xref_map.insert(by_number, EntryType::Normal(cur_pos)); + aliases.insert(by_number); + } last_obj_num = Some(obj_id); dummy_ctx.set_obj_number(obj_id); } @@ -487,12 +582,24 @@ impl XRef { // Try to yield in the order the objects appeared in the // PDF. elements.sort_by(|e1, e2| e1.1.cmp(&e2.1)); + // PdfCraft patch: one object per location. A rebuilt table can hold two keys for + // one object -- its own generation and the generation-0 stand-in (#5580) -- and + // enumeration yielded the object once for each, so a document recovered by + // `Pages::new_brute_force` reported the page twice (noted in review). Both keys + // stay for lookups; only this walk is deduplicated. The object number is part of + // the key: an object stream at (offset, 0) and its first member, also + // (offset, 0), are two objects. + elements.dedup_by(|a, b| a.1 == b.1 && a.0.obj_number == b.0.obj_number); let mut iter = elements.into_iter(); iter::from_fn(move || { for next in iter.by_ref() { - if let Some(obj) = self.get_with(next.0, &ReaderContext::new(self, false)) { + // PdfCraft patch: (hayro-syntax (33)) an object that only refers to another + // isn't one of its own: followed, that one came out once more for each. + if let Some(Fetched::Object(obj)) = + self.fetch(next.0, &ReaderContext::new(self, false)) + { return Some(obj); } else { // Skip invalid objects. @@ -563,12 +670,48 @@ impl XRef { } /// Return the object with the given identifier. + /// + /// PdfCraft patch: (hayro-syntax (33)) an object that holds only a reference stands for what + /// that names. Read as a number it was the reference's own object number, and as anything else + /// it was lost. This is a loop, so a long chain takes no stack. #[allow(private_bounds)] pub(crate) fn get_with<'a, T>( &'a self, id: ObjectIdentifier, ctx: &ReaderContext<'a>, ) -> Option + where + T: ObjectLike<'a>, + { + let mut id = id; + for _ in 0..=MAX_REFERENCE_HOPS { + match self.fetch::(id, ctx)? { + Fetched::Object(object) => return Some(object), + Fetched::Reference(next) => id = next, + } + } + warn!("references lead on past the limit, or in a cycle: {id:?}"); + + None + } + + /// PdfCraft patch: (hayro-syntax (33)) the object stream `id`. It has to be a stream stored as + /// that object (ISO 32000-2 7.5.7), so this follows no reference: the check of where it is stored + /// is of that object, and one reached through a reference could be a stream stored inside + /// another. + pub(crate) fn object_stream<'a>( + &'a self, + id: ObjectIdentifier, + ctx: &ReaderContext<'a>, + ) -> Option> { + match self.fetch::>(id, ctx)? { + Fetched::Object(stream) => Some(stream), + Fetched::Reference(_) => None, + } + } + + /// What is stored at the object with the given identifier. + fn fetch<'a, T>(&'a self, id: ObjectIdentifier, ctx: &ReaderContext<'a>) -> Option> where T: ObjectLike<'a>, { @@ -597,11 +740,32 @@ impl XRef { ctx.set_in_object_stream(false); r.jump(offset); + if let Some(next) = held_reference(r.clone(), id) { + return Some(Fetched::Reference(next)); + } + if let Some(object) = r.read_with_context::>(&ctx) { - if object.id() == &id { - return Some(object.get()); + // PdfCraft patch: in a table rebuilt by scanning the file, the object number + // is what identifies an object. There is no revision history to tell two + // generations apart, the scan already files objects found in object streams + // under generation 0, and a malformed generation in a header would otherwise + // make the object unreadable through the reference that names it (#5580). A + // table read from the file's own xref keeps the exact match. + if object.id() == &id || (repaired && object.id().obj_number == id.obj_number) { + return Some(Fetched::Object(object.get())); } } else { + // PdfCraft patch: the strict read of the header failed. In a table rebuilt by + // scanning the file that can be a generation number no generation may be -- + // `3 18446744073709551616 obj` -- and refusing it loses the object the + // reference names (#5580). The object number still has to be the one asked + // for; only an unreadable generation is tolerated, and only here. + if repaired + && let Some(object) = + read_repaired_body::(repr.data.get().as_ref(), offset, id, &ctx) + { + return Some(Fetched::Object(object)); + } // There is a valid object at the offset, it's just not of the type the caller // expected, which is fine. if r.skip::>>(false).is_some() { @@ -622,7 +786,7 @@ impl XRef { self.repair(); // Now try reading again. - self.get_with::(id, &ctx) + self.fetch::(id, &ctx) } } EntryType::ObjStream(obj_stram_gen_num, index) => { @@ -657,11 +821,20 @@ impl XRef { // A stream whose pairs don't parse stays unreadable, as it was. Some(offsets) => offsets?, None => { - let stream = self.get_with::>(obj_stream_id, &ctx)?; + // PdfCraft patch: (hayro-syntax (33)) this read can reach another object + // stream, whose `/N` or `/First` can be a member of the next: the gate's + // limit holds here too, or a chain of them ran out of stack. It reads the + // dictionary, which is all it needs: parsing the stream reads `/Length` + // too, a second way to the next stream's index, which doubled at each. + let _loading = repr.data.enter_loading(obj_stream_id)?; + let Fetched::Object(dict) = self.fetch::>(obj_stream_id, &ctx)? + else { + return None; + }; // `/N` and `/First` may be references that can't be followed while // another stream loads: reading them fails without being remembered. - let num_objects = stream.dict().get::(N)?; - let first_offset = stream.dict().get::(FIRST)?; + let num_objects = dict.get::(N)?; + let first_offset = dict.get::(FIRST)?; let parsed = read_object_stream_index(num_objects, first_offset, data).map(Arc::new); self.with_object_stream_indexes(|indexes| { @@ -673,7 +846,12 @@ impl XRef { .unwrap_or(parsed)? } }; - ObjectStream::with_offsets(data, &ctx, offsets).get(index) + let stream = ObjectStream::with_offsets(data, &ctx, offsets); + if let Some(next) = stream.reference(index) { + return Some(Fetched::Reference(next)); + } + + stream.get(index).map(Fetched::Object) } } } @@ -1232,6 +1410,28 @@ impl<'a> ObjectStream<'a> { r.read_with_context::(&self.ctx) } + + /// PdfCraft patch: (hayro-syntax (33)) what the object at `index` refers to, when a reference is + /// all it holds. + fn reference(&self, index: u32) -> Option { + let offset = self.offsets.get(index as usize)?.1; + let mut r = Reader::new(self.data); + r.jump(offset); + r.skip_white_spaces_and_comments(); + + Some(r.read_without_context::()?.into()) + } +} + +/// PdfCraft patch: (hayro-syntax (33)) what the indirect object `id` at `r` refers to, when a +/// reference is all it holds. +fn held_reference(mut r: Reader<'_>, id: ObjectIdentifier) -> Option { + if r.read_without_context::()? != id { + return None; + } + r.skip_white_spaces_and_comments(); + + Some(r.read_without_context::()?.into()) } fn parse_metadata(info_dict: &Dict<'_>) -> Metadata { @@ -1266,6 +1466,203 @@ fn parse_metadata(info_dict: &Dict<'_>) -> Metadata { #[cfg(test)] mod tests { use super::*; + use crate::Pdf; + + /// A file with an xref stream: `stored` are objects on their own, as they would follow + /// `n 0 obj`, and `packed` says of each object in an object stream which stream and place. + fn with_xref_stream(stored: &[(u32, Vec)], packed: &[(u32, u32, u16)]) -> Vec { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = alloc::collections::BTreeMap::new(); + for (num, body) in stored { + offsets.insert(*num, out.len()); + out.extend_from_slice(alloc::format!("{num} 0 obj ").as_bytes()); + out.extend_from_slice(body); + out.extend_from_slice(b"\nendobj\n"); + } + let last = stored.iter().map(|o| o.0).chain(packed.iter().map(|o| o.0)).max().unwrap_or(0); + let (xref, size) = (last + 1, last + 2); + let at = out.len(); + offsets.insert(xref, at); + let packed: alloc::collections::BTreeMap = packed.iter().map(|o| (o.0, (o.1, o.2))).collect(); + let mut rows = Vec::new(); + for num in 0..size { + if let Some(offset) = offsets.get(&num) { + rows.push(1); + rows.extend_from_slice(&(*offset as u32).to_be_bytes()); + rows.extend_from_slice(&[0, 0]); + } else if let Some((stream, index)) = packed.get(&num) { + rows.push(2); + rows.extend_from_slice(&stream.to_be_bytes()); + rows.extend_from_slice(&index.to_be_bytes()); + } else { + rows.extend_from_slice(&[0; 7]); + } + } + out.extend_from_slice( + alloc::format!( + "{xref} 0 obj << /Type /XRef /Size {size} /W [1 4 2] /Root 1 0 R /Length {} >> stream\n", + rows.len() + ) + .as_bytes(), + ); + out.extend_from_slice(&rows); + out.extend_from_slice(alloc::format!("\nendstream endobj\nstartxref\n{at}\n%%EOF\n").as_bytes()); + out + } + + /// `/Type /ObjStm` around `members`, each stored at its offset, and `entries` of its own. + fn object_stream_of(members: &[(u32, &[u8])], entries: &str) -> Vec { + let (mut head, mut body) = (alloc::string::String::new(), Vec::new()); + for (num, member) in members { + head.push_str(&alloc::format!("{num} {} ", body.len())); + body.extend_from_slice(member); + body.push(b'\n'); + } + head.push('\n'); + let dict = alloc::format!( + "<< /Type /ObjStm /N {} /First {} /Length {} {entries} >> stream\n", + members.len(), + head.len(), + head.len() + body.len() + ); + let mut out = dict.into_bytes(); + out.extend_from_slice(head.as_bytes()); + out.extend_from_slice(&body); + out.extend_from_slice(b"\nendstream"); + out + } + + fn page_objects() -> Vec<(u32, Vec)> { + vec![ + (1, b"<< /Type /Catalog /Pages 2 0 R >>".to_vec()), + (2, b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec()), + (3, b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 10 10] >>".to_vec()), + ] + } + + /// An object stream is a stream stored as that object, however many references lead to one. + /// Object 10 holds a reference to 11, a stream stored inside object stream 12, and 31 claims to + /// be in 10, with 11's `/N`, `/First` or `/Length` being 31: reading it recursed until the + /// stack ran out. It reads as missing, and so does a member of such a stream. + #[test] + fn an_object_stream_isnt_reached_through_a_reference() { + for key in ["N", "First", "Length"] { + let inner = alloc::format!("<< /Type /ObjStm /{key} 31 0 R >> stream\n31 0 1\n\nendstream"); + let mut stored = page_objects(); + stored.push((10, b"11 0 R".to_vec())); + stored.push((12, object_stream_of(&[(11, inner.as_bytes())], ""))); + let pdf = Pdf::new(with_xref_stream(&stored, &[(11, 12, 0), (31, 10, 0)])).unwrap(); + assert_eq!(pdf.xref().get::(ObjectIdentifier::new(31, 0)), None, "{key}"); + } + let inner = b"<< /Type /ObjStm /N 1 /First 5 /Length 8 >> stream\n20 0 7\n\nendstream"; + let mut stored = page_objects(); + stored.push((10, b"11 0 R".to_vec())); + stored.push((12, object_stream_of(&[(11, inner)], ""))); + let pdf = Pdf::new(with_xref_stream(&stored, &[(11, 12, 0), (20, 10, 0)])).unwrap(); + assert_eq!(pdf.xref().get::(ObjectIdentifier::new(20, 0)), None); + } + + /// `count` object streams stored as themselves, the `keys` of each a member of the next, or + /// with `ring` of the first after the last. Reading the first member reads them all, and it is + /// `count + 100` to tell. + #[cfg(feature = "std")] + fn chain_of_object_streams(count: u32, keys: &[&str], ring: bool) -> Vec { + let mut stored = page_objects(); + let mut packed = Vec::new(); + for i in 0..count { + let (stream, member) = (100 + 2 * i, 101 + 2 * i); + let value = if i == 0 { count + 100 } else { 1 }; + let data = alloc::format!("{member} 0 \n{value}\n"); + let (first, length) = (alloc::format!("{}", alloc::format!("{member} 0 \n").len()), alloc::format!("{}", data.len())); + let next = match (i + 1 == count, ring) { + (true, true) => "101 0 R".into(), + (true, false) => "1".into(), + _ => alloc::format!("{} 0 R", member + 2), + }; + let entry = |key, own: &'_ str| if keys.contains(&key) { next.clone() } else { own.into() }; + let (n, first, length): (alloc::string::String, alloc::string::String, alloc::string::String) = + (entry("N", "1"), entry("First", &first), entry("Length", &length)); + let body = alloc::format!("<< /Type /ObjStm /N {n} /First {first} /Length {length} >> stream\n{data}\nendstream"); + stored.push((stream, body.into_bytes())); + packed.push((member, stream, 0)); + } + with_xref_stream(&stored, &packed) + } + + /// A chain of object streams, each with a member of the next for its `/N`, `/First` or + /// `/Length`, had no limit: one read recursed through all of them, holding a copy of the chain + /// at each, and 50,000 ran out of an 8 MiB stack. It stops at the gate's limit, on half the stack + /// of a render worker. Past that `/N` and `/First` can't be read, so the member reads as missing, + /// and a `/Length` that can't be read falls back on `endstream`, so it reads. A short chain reads. + #[cfg(feature = "std")] + #[test] + fn a_chain_of_object_streams_ends_at_the_limit_of_nested_loads() { + for key in ["N", "First", "Length"] { + let pdf = chain_of_object_streams(50_000, &[key], false); + std::thread::Builder::new() + .stack_size(1 << 20) + .spawn(move || { + let pdf = Pdf::new(pdf).unwrap(); + let read = pdf.xref().get::(ObjectIdentifier::new(101, 0)); + assert_eq!(read, (key == "Length").then_some(50_100), "{key}"); + }) + .unwrap() + .join() + .unwrap(); + } + let pdf = Pdf::new(chain_of_object_streams(10, &["N"], false)).unwrap(); + assert_eq!(pdf.xref().get::(ObjectIdentifier::new(101, 0)), Some(110)); + } + + /// `/Length` and `/N` or `/First` both by reference were two ways to the next stream's index, + /// and an index that fails isn't remembered, so the work doubled at each stream: 65 streams, one + /// past the limit, in 8 KB, didn't finish, and other threads waited at the gate. The index reads + /// the dictionary alone now. Without that this doesn't end, so the time allowed can't fail it. + #[cfg(feature = "std")] + #[test] + fn a_chain_of_object_streams_takes_no_more_work_for_two_ways_on() { + for (count, ring) in [(65, false), (20, true), (5_000, false)] { + for keys in [&["Length", "N"][..], &["Length", "First"]] { + let pdf = chain_of_object_streams(count, keys, ring); + let (done, finished) = std::sync::mpsc::channel(); + std::thread::Builder::new() + .stack_size(1 << 20) + .spawn(move || { + let pdf = Pdf::new(pdf).unwrap(); + let _ = pdf.xref().get::(ObjectIdentifier::new(101, 0)); + let _ = done.send(()); + }) + .unwrap(); + assert!(finished.recv_timeout(std::time::Duration::from_secs(120)).is_ok(), "{keys:?} of {count}"); + } + } + } + + /// Object 5, a member of a real object stream, holds a reference to object 6 stored on its own, + /// and 7, stored on its own, to 8, a member: each reads as what it names. + #[test] + fn a_reference_held_in_either_storage_is_followed() { + let mut stored = page_objects(); + stored.push((6, b"42".to_vec())); + stored.push((7, b"8 0 R".to_vec())); + stored.push((9, object_stream_of(&[(5, b"6 0 R"), (8, b"43")], ""))); + let pdf = Pdf::new(with_xref_stream(&stored, &[(5, 9, 0), (8, 9, 1)])).unwrap(); + assert_eq!(pdf.xref().get::(ObjectIdentifier::new(5, 0)), Some(42)); + assert_eq!(pdf.xref().get::(ObjectIdentifier::new(7, 0)), Some(43)); + } + + /// With no page tree, every page object is a page, and one that another object only refers to + /// is one page, not two. + #[test] + fn a_page_a_reference_is_held_to_is_found_once() { + let mut stored = page_objects(); + stored[0].1 = b"<< /Type /Catalog /Pages 99 0 R >>".to_vec(); + stored[2].1 = b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 10 10] /Contents 4 0 R >>".to_vec(); + stored.push((4, b"<< /Length 0 >> stream\n\nendstream".to_vec())); + stored.push((12, b"3 0 R".to_vec())); + let pdf = Pdf::new(with_xref_stream(&stored, &[])).unwrap(); + assert_eq!(pdf.pages().len(), 1); + } // PdfCraft patch tests (ported from craft-libs): xref stream entries are bounded by the // decoded data, and the type field is read at its full width. @@ -1446,6 +1843,18 @@ mod tests { } } + #[test] + fn an_object_stream_and_its_first_member_are_both_enumerated() { + // Both sit at (stream offset, 0) in `objects`' ordering; one object per location must + // not cost either of them. + let pdf = crate::Pdf::new(object_stream_fixture(false)).unwrap(); + let objects: Vec<_> = pdf.xref().objects().into_iter().collect(); + assert!(objects.iter().any(|o| matches!(o, Object::Stream(_)))); + assert!(objects.iter().any( + |o| matches!(o, Object::Dict(d) if d.get::(b"Marker") == Some(123)) + )); + } + #[test] fn recursive_object_stream_locations_fail_without_recursing() { let pdf = crate::Pdf::new(object_stream_fixture(true)).unwrap(); diff --git a/vendor/hayro/src/renderer.rs b/vendor/hayro/src/renderer.rs index 242c54ec2b..0b9cfe779e 100644 --- a/vendor/hayro/src/renderer.rs +++ b/vendor/hayro/src/renderer.rs @@ -401,30 +401,55 @@ fn may_paint_rect(rect: Rect, transform: Affine, width: u16, height: u16) -> boo paints_canvas(device, width, height) } -/// PdfCraft patch: the whole-pixel device rectangle of a shrunk, axis-aligned image, as -/// `(width, height, x0, y0)`. `transform` maps source pixels to device pixels and `planned` is the -/// size [`image_resampling_size`] chose; `None` (draw as before) for rotated, skewed or flipped -/// images, non-finite geometry, or a snapped size that strays more than a pixel from `planned` -/// (so the memory budget that size passed still holds). +/// PdfCraft patch: how far, in device pixels, the terms [`snap_minified_image`] drops may move an +/// image's far edges: a hundredth of a pixel, which no edge shows. Rounding noise in a turned or +/// scaled matrix stays far below it. +const SNAP_SKEW: f64 = 0.01; + +/// PdfCraft patch: the whole-pixel device rectangle of a shrunk image whose axes lie along the +/// device's, as `(width, height, transform)`: the size to shrink it to (along its own axes) and +/// the transform that draws that raster 1:1, one source-axis step a whole device pixel. Upright, +/// mirrored and quarter-turned images all qualify, so a page under `/Rotate` (or an image placed +/// turned) is drawn as sharply as upright (#5530). `transform` maps source pixels to device +/// pixels and `planned` is the size [`image_resampling_size`] chose; `None` (draw as before) for +/// skewed or freely rotated images, non-finite geometry, or a snapped size that strays more than +/// a pixel from `planned` (so the memory budget that size passed still holds). An image counts +/// as axis-aligned when the terms the snap drops move its far edges by at most [`SNAP_SKEW`]: a +/// tolerance on the coefficients alone let a slight skew of a very wide image through, though it +/// moved the far edge by more than a pixel. fn snap_minified_image( transform: &Affine, source: (u32, u32), planned: (u32, u32), -) -> Option<(u32, u32, f64, f64)> { +) -> Option<(u32, u32, Affine)> { let [a, b, c, d, e, f] = transform.as_coeffs(); - let tiny = 1e-4 * a.abs().max(d.abs()); - if !(a > 0.0 && d > 0.0 && b.abs() <= tiny && c.abs() <= tiny) { - return None; - } + // The device span of a source edge from `origin` across `side` samples of `scale`, rounded. let edge = |origin: f64, scale: f64, side: u32| { - let (lo, hi) = (origin.round(), (origin + scale * f64::from(side)).round()); + let (p, q) = (origin.round(), (origin + scale * f64::from(side)).round()); + let (lo, hi) = (p.min(q), p.max(q)); (hi - lo >= 1.0 && lo.abs() < 1e6 && hi.abs() < 1e6).then_some((lo, hi)) }; - let (x0, x1) = edge(e, a, source.0)?; - let (y0, y1) = edge(f, d, source.1)?; - // Bounded by the 1e6 check above, so the casts cannot saturate. - let (w, h) = ((x1 - x0) as u32, (y1 - y0) as u32); - (w.abs_diff(planned.0) <= 1 && h.abs_diff(planned.1) <= 1).then_some((w, h, x0, y0)) + // Along an axis drawn backwards (negative scale), the raster starts at the span's far end. + let start = |scale: f64, (lo, hi): (f64, f64)| if scale > 0.0 { lo } else { hi }; + let step = |scale: f64| if scale > 0.0 { 1.0 } else { -1.0 }; + // How far a dropped term moves the image's far edge: `scale` across `side` source samples. + let negligible = |scale: f64, side: u32| (scale * f64::from(side)).abs() <= SNAP_SKEW; + let (width, height) = source; + // Source x along device x, source y along device y (upright or mirrored), or a quarter turn: + // source x along device y, source y along device x. + let (w, h, draw) = if negligible(b, width) && negligible(c, height) { + let (x, y) = (edge(e, a, source.0)?, edge(f, d, source.1)?); + // Bounded by the 1e6 check above, so the casts cannot saturate. + let (w, h) = ((x.1 - x.0) as u32, (y.1 - y.0) as u32); + (w, h, Affine::new([step(a), 0.0, 0.0, step(d), start(a, x), start(d, y)])) + } else if negligible(a, width) && negligible(d, height) { + let (x, y) = (edge(e, c, source.1)?, edge(f, b, source.0)?); + let (w, h) = ((y.1 - y.0) as u32, (x.1 - x.0) as u32); + (w, h, Affine::new([0.0, step(b), step(c), 0.0, start(c, x), start(b, y)])) + } else { + return None; + }; + (w.abs_diff(planned.0) <= 1 && h.abs_diff(planned.1) <= 1).then_some((w, h, draw)) } /// PdfCraft patch (19): what `Renderer::set_paint` found out: the clip path to apply while @@ -436,6 +461,11 @@ enum PaintSetup { Nothing, } +/// PdfCraft patch (27): the largest group, in device pixels, blended in CMYK (2^24, a 4096 × +/// 4096 square). Each pixel costs about 20 bytes more than drawing the group as before (two +/// pass canvases, its tints, their RGB and the result); a larger group is drawn as before. +const MAX_CMYK_GROUP_PIXELS: u64 = 1 << 24; + pub(crate) struct Renderer { pub(crate) ctx: RenderContext, /// PdfCraft patch: when set, solid text and line-art paint colours are remapped @@ -451,6 +481,9 @@ pub(crate) struct Renderer { /// how many transparency groups were skipped in it (nothing is layered while erasing). erasing: bool, erase_groups: usize, + /// PdfCraft patch (26): the knockout groups drawn in blend-mode passes + /// (`Device::begin_knockout_pass`), innermost last. + knockout_passes: Vec, pub(crate) inside_pattern: bool, /// Soft masks drawn during this render. PdfCraft note: it belongs to one `Renderer` (one /// render, every nested renderer starting empty) and so to one `preview` setting: a mask @@ -464,6 +497,50 @@ pub(crate) struct Renderer { pub(crate) preview: bool, // TODO: Remove this once vello_cpu bug with non-transparent images is fixed image_transparency_stack: Vec, + /// PdfCraft patch (27): set while this renderer draws one ink pass of a group blending + /// in `DeviceCMYK` (see [`Renderer::draw_isolated_cmyk_group`]). + ink: InkState, +} + +/// PdfCraft patch (27): which inks a pass of a `DeviceCMYK` group draws, and whether the +/// group asked for something a pass can't draw. +/// +/// A pass paints the complement of ink tints as colour — no ink is white — so the RGB blend +/// functions compute ISO 32000-2 §11.3.4's subtractive blend, `1 − B(1 − cb, 1 − cs)`, and +/// complementing the pixels back gives the tints. The cyan, magenta and yellow pass paints +/// them as red, green and blue, which is also how §11.3.5.3 has the nonseparable modes treat +/// them. The black pass paints black ink as a grey, so a separable mode blends it like any +/// tint, and of the nonseparable modes Hue, Saturation and Color keep the backdrop's grey +/// while Luminosity takes the source's: §11.3.5.3's rule for the K component. +#[derive(Clone, Copy, Default)] +struct InkState { + pass: Option, + /// Something in the group has no ink tints (an RGB colour, a pattern, an image): the + /// group is drawn as before instead. + unsupported: bool, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum InkPass { + /// Cyan, magenta and yellow as red, green and blue. + Cmy, + /// Black as grey. + K, +} + +impl InkState { + /// The colour a pass paints for `color`; transparent, with the group marked unsupported, + /// when the colour has no ink tints. + fn paint(&mut self, pass: InkPass, color: &Color) -> AlphaColor { + let Some(([c, m, y, k], alpha)) = color.ink_tints() else { + self.unsupported = true; + return AlphaColor::TRANSPARENT; + }; + match pass { + InkPass::Cmy => AlphaColor::new([1.0 - c, 1.0 - m, 1.0 - y, alpha]), + InkPass::K => AlphaColor::new([1.0 - k, 1.0 - k, 1.0 - k, alpha]), + } + } } #[derive(Clone, Copy)] @@ -479,6 +556,32 @@ impl Renderer { self.knockout.last().copied().unwrap_or(false) } + /// PdfCraft patch (26): the blend-mode pass of the innermost group, when that group is a + /// knockout group drawn in passes, so the element now drawn is one of its own elements. + fn knockout_pass(&mut self) -> Option<&mut KnockoutPass> { + let depth = self.knockout.len(); + if !self.in_knockout() { + return None; + } + self.knockout_passes.last_mut().filter(|pass| pass.depth == depth) + } + + /// PdfCraft patch (26): whether an element of blend mode `mode` belongs to another pass than + /// the current one, so here it clears what is under its shape instead of painting. Records + /// the mode for its own pass. + fn knockout_pass_skips(&mut self, mode: BlendMode) -> bool { + let Some(pass) = self.knockout_pass() else { + return false; + }; + if mode == pass.mode { + return false; + } + if !pass.other_modes.contains(&mode) { + pass.other_modes.push(mode); + } + true + } + pub(crate) fn new( width: u16, height: u16, @@ -493,6 +596,7 @@ impl Renderer { in_annotations: false, knockout: Vec::new(), erasing: false, + knockout_passes: Vec::new(), erase_groups: 0, inside_pattern: false, soft_mask_cache: HashMap::default(), @@ -502,6 +606,7 @@ impl Renderer { scaler: Scaler::new(ResamplingFunction::CatmullRom), preview, image_transparency_stack: Vec::new(), + ink: InkState::default(), } } @@ -597,6 +702,7 @@ impl Renderer { in_annotations: false, knockout: Vec::new(), erasing: false, + knockout_passes: Vec::new(), erase_groups: 0, inside_pattern: false, soft_mask_cache: HashMap::default(), @@ -606,6 +712,7 @@ impl Renderer { scaler: self.scaler, preview: self.preview, image_transparency_stack: Vec::new(), + ink: InkState::default(), }; let mut mask_pix = Pixmap::new(self.ctx.width(), self.ctx.height()); let rgb_data = ImageData::Rgb(RgbData { @@ -878,10 +985,11 @@ impl Renderer { if requested_resize && self.in_type3_glyph && interpolate { quality = ImageQuality::High; } - // PdfCraft patch: an axis-aligned, interpolated image that is shrunk lands on whole device - // pixels. It is shrunk once to exactly its snapped device rectangle and drawn 1:1 at the - // snapped corner. Otherwise the shrunk raster (rounded up to a whole size) is sampled a - // second time at a fractional scale and offset, which blurs scans and photos. + // PdfCraft patch: an interpolated image that is shrunk, with its axes along the device's + // (upright, mirrored or a quarter turn), lands on whole device pixels. It is shrunk once to + // exactly its snapped device rectangle and drawn 1:1 from the snapped corner. Otherwise the + // shrunk raster (rounded up to a whole size) is sampled a second time at a fractional scale + // and offset, which blurs scans and photos. let snapped = if interpolate && requested_resize && !self.in_type3_glyph @@ -1040,9 +1148,12 @@ impl Renderer { img_height as u16, may_have_transparency, ); + // The snapped transform draws the raster 1:1, so it holds only if the shrink produced the + // snapped size. A failed resize hands back the source raster: drawn 1:1 it would come out + // at its source size, so it keeps the full transform instead. let draw_transform = match snapped { - Some((_, _, x0, y0)) => Affine::translate((x0, y0)), - None => cur_transform * additional_transform, + Some((w, h, draw)) if (img_width, img_height) == (w, h) => draw, + _ => cur_transform * additional_transform, }; self.draw_pixmap(Arc::new(pixmap), quality, draw_transform); } @@ -1075,6 +1186,54 @@ impl Renderer { .fill_rect(&Rect::new(0.0, 0.0, width as f64, height as f64)); } + /// PdfCraft patch (27): one ink pass of `group` (see [`InkState`]) on a `width` × `height` + /// canvas whose corner is device point `offset`; `None` when the group holds something the + /// pass has no tints for. + fn ink_pass( + &self, + group: &hayro_interpret::CmykGroup<'_, '_>, + pass: InkPass, + offset: (f64, f64), + width: u16, + height: u16, + ) -> Option { + let mut renderer = Self { + ctx: RenderContext::new_with(width, height, derive_settings(self.ctx.render_settings())), + color_replacement: None, + in_annotations: self.in_annotations, + inside_pattern: self.inside_pattern, + soft_mask_cache: HashMap::default(), + outline_cache: self.outline_cache.clone(), + cur_mask: None, + in_type3_glyph: false, + scaler: Scaler::new(ResamplingFunction::CatmullRom), + preview: self.preview, + image_transparency_stack: Vec::new(), + ink: InkState { + pass: Some(pass), + unsupported: false, + }, + knockout: Vec::new(), + erasing: false, + erase_groups: 0, + knockout_passes: Vec::new(), + }; + // The pass draws inside a layer of its own. On the bare canvas, vello_cpu fills a tile + // that a paint covers completely without the blend mode, so the middle of a group wider + // than about two tiles lost its blend (#3777). + renderer.ctx.push_layer(None, None, None, None, None); + group.interpret(&mut renderer, offset); + renderer.ctx.pop_layer(); + if renderer.ink.unsupported { + return None; + } + let mut pix = Pixmap::new(width, height); + renderer.ctx.flush(); + let mut resources = vello_cpu::Resources::default(); + renderer.ctx.render_to_pixmap(&mut resources, &mut pix); + Some(pix) + } + /// PdfCraft patch: remaps a solid paint colour through `color_replacement`, if one is /// set. `Paint::Pattern` (images, shadings, tiling patterns) always passes through /// untouched. @@ -1099,28 +1258,101 @@ impl Renderer { } } + /// The device-space area a shading painted on `path` covers: the path's bounding box, with + /// the stroke width around it for a stroke, on the canvas. + fn shading_bbox(&self, path: &BezPath, is_stroke: bool) -> Rect { + let path_transform = self.ctx.transform(); + let mut bbox = (*path_transform * path.clone()).bounding_box(); + if is_stroke { + // Try to account for stroke in bbox. + let (a1, a2) = x_y_advances(path_transform); + let factor = a1.length().max(a2.length()) * self.ctx.stroke().width; + bbox = bbox.inflate(factor, factor); + } + bbox.intersect(Rect::new( + 0.0, + 0.0, + self.ctx.width() as f64, + self.ctx.height() as f64, + )) + } + #[must_use] fn set_paint(&mut self, paint: &Paint<'_>, path: &BezPath, is_stroke: bool) -> PaintSetup { // PdfCraft patch (22): in a knockout erase pass every paint clears what is under its // shape (a transparent fill with `Compose::Copy`, weighted by coverage). - if self.erasing { + let element_mode = self.knockout_pass().map(|pass| pass.element_mode); + let other_pass = + !self.erasing && element_mode.is_some_and(|mode| self.knockout_pass_skips(mode)); + if self.erasing || other_pass { + if is_shading(paint) { + self.knockout_shading(paint, path, is_stroke, false); + return PaintSetup::Nothing; + } self.ctx.set_blend_mode(knockout_blend_mode()); self.ctx.set_paint(AlphaColor::::new([0.0, 0.0, 0.0, 0.0])); - // A shading's `/BBox` clips what it paints, so it clips what it clears too. - return PaintSetup::Clip(match paint { - Paint::Pattern(p) => match p.as_ref() { - Pattern::Shading(s) => s.shading.clip_path.clone(), - _ => None, - }, - _ => None, - }); + return PaintSetup::Clip(None); + } + if self.in_knockout() && is_shading(paint) { + self.knockout_shading(paint, path, is_stroke, true); + return PaintSetup::Nothing; + } + self.paint_setup(paint, path, is_stroke) + } + + /// PdfCraft patch (26): a shading in a knockout group replaces what is under it where it + /// paints, which a mesh's triangles or the shading's `/BBox` may make less than the path, + /// so it can't be copied over the path like other paints. The shading at full opacity, + /// composited with `Compose::DestOut`, clears its shape; then, when `paint_it`, the shading + /// is painted over the cleared area. A shading that paints nothing clears nothing. The + /// caller paints nothing more. + fn knockout_shading(&mut self, paint: &Paint<'_>, path: &BezPath, is_stroke: bool, paint_it: bool) { + let PaintSetup::Clip(clip) = self.paint_setup(&shape_of(paint), path, is_stroke) else { return }; + if let Some(clip) = clip.as_ref() { + self.push_clip_path_inner(clip, FillRule::NonZero); + } + self.ctx.set_blend_mode(peniko::BlendMode::new(Mix::Normal, Compose::DestOut)); + self.draw_shape(path, is_stroke); + if paint_it && let PaintSetup::Clip(_) = self.paint_setup(paint, path, is_stroke) { + self.ctx.set_blend_mode(peniko::BlendMode::new(Mix::Normal, Compose::SrcOver)); + self.draw_shape(path, is_stroke); + } + if clip.is_some() { + self.ctx.pop_clip_path(); } + self.ctx.set_blend_mode(knockout_blend_mode()); + } + + fn draw_shape(&mut self, path: &BezPath, is_stroke: bool) { + if is_stroke { + self.ctx.stroke_path(path); + } else { + self.ctx.fill_path(path); + } + } + + /// The paint `paint` draws on `path` with, set on the context (see [`PaintSetup`]). + fn paint_setup(&mut self, paint: &Paint<'_>, path: &BezPath, is_stroke: bool) -> PaintSetup { let mut paint_transform = Affine::IDENTITY; let mut clip_path = None; // PdfCraft patch (19): `Extend::Repeat` tiles the texture over the path; a mesh texture // covers its triangles alone and must not repeat outside them. let mut extend = peniko::Extend::Repeat; + // PdfCraft patch (27): an ink pass paints tints, and has none for a pattern. + if let Some(pass) = self.ink.pass { + let color = match paint { + Paint::Color(c) => self.ink.paint(pass, c), + Paint::Pattern(_) => { + self.ink.unsupported = true; + AlphaColor::TRANSPARENT + } + }; + self.ctx.set_paint_transform(Affine::IDENTITY); + self.ctx.set_paint(color); + return PaintSetup::Clip(None); + } + let paint: PaintType = match paint.clone() { Paint::Color(c) => { let c = c.to_rgba().to_rgba8(); @@ -1132,21 +1364,7 @@ impl Renderer { match *p { Pattern::Shading(s) => { clip_path = s.shading.clip_path.clone(); - let mut bbox = (*path_transform * path.clone()).bounding_box(); - - if is_stroke { - // Try to account for stroke in bbox. - let (a1, a2) = x_y_advances(path_transform); - let factor = a1.length().max(a2.length()) * self.ctx.stroke().width; - bbox = bbox.inflate(factor, factor); - } - - bbox = bbox.intersect(Rect::new( - 0.0, - 0.0, - self.ctx.width() as f64, - self.ctx.height() as f64, - )); + let mut bbox = self.shading_bbox(path, is_stroke); // PdfCraft patch: mesh shadings are sampled only inside `bbox` (and a // pixel around it: texels at a fractional edge look one pixel further). @@ -1159,18 +1377,11 @@ impl Renderer { // now covers the triangles alone (a pixel around them, which stays // transparent and is what `Extend::Pad` repeats), and a mesh with no // triangle on the canvas paints nothing at all. - if let Some(extent) = encoded.extent { - // An empty extent (no triangle in the area) is `Rect::ZERO`, which - // sits at the canvas origin: test it before intersecting, or a - // 1 × 1 transparent texture would still be painted over the whole - // path (310 of them cost 230 ms per tile). - if extent.width() <= 0.0 || extent.height() <= 0.0 { - return PaintSetup::Nothing; - } - bbox = bbox.intersect(extent.inflate(1.0, 1.0)); - if bbox.width() <= 0.0 || bbox.height() <= 0.0 { + if encoded.extent.is_some() { + let Some(area) = mesh_area(bbox, &encoded) else { return PaintSetup::Nothing; - } + }; + bbox = area; extend = peniko::Extend::Pad; } @@ -1265,6 +1476,7 @@ impl Renderer { in_annotations: false, knockout: Vec::new(), erasing: false, + knockout_passes: Vec::new(), erase_groups: 0, cur_mask: None, inside_pattern: true, @@ -1274,6 +1486,7 @@ impl Renderer { scaler: self.scaler, preview: self.preview, image_transparency_stack: Vec::new(), + ink: InkState::default(), }; let initial_transform = Affine::translate((BORDER, BORDER)) * Affine::scale_non_uniform(xs as f64, ys as f64) @@ -1327,6 +1540,7 @@ impl Renderer { in_annotations: false, knockout: Vec::new(), erasing: false, + knockout_passes: Vec::new(), erase_groups: 0, cur_mask: None, inside_pattern: true, @@ -1336,6 +1550,7 @@ impl Renderer { scaler: self.scaler, preview: self.preview, image_transparency_stack: Vec::new(), + ink: InkState::default(), }; let mut initial_transform = Affine::scale_non_uniform(xs, ys) * Affine::translate((-bbox.x0, -bbox.y0)); @@ -1521,6 +1736,12 @@ impl Renderer { impl<'a> Device<'a> for Renderer { fn draw_image(&mut self, image: hayro_interpret::Image<'a, '_>, mut transform: Affine) { + // PdfCraft patch (27): an image's pixels are converted to RGB before they reach the + // device, so an ink pass has no tints for them; the group is drawn as before. + if self.ink.pass.is_some() { + self.ink.unsupported = true; + return; + } // PdfCraft patch (22): an erase pass paints nothing; an unmasked image's rectangle was // cleared by `knockout_shape`, and a masked image's shape is left alone. if self.erasing { @@ -1625,6 +1846,7 @@ impl<'a> Device<'a> for Renderer { in_annotations: false, knockout: Vec::new(), erasing: false, + knockout_passes: Vec::new(), erase_groups: 0, inside_pattern: false, soft_mask_cache: HashMap::default(), @@ -1634,6 +1856,7 @@ impl<'a> Device<'a> for Renderer { scaler: self.scaler, preview: self.preview, image_transparency_stack: Vec::new(), + ink: InkState::default(), }; let mut sub_pix = Pixmap::new(width, height); sub_renderer.ctx.set_transform(transform); @@ -1697,6 +1920,87 @@ impl<'a> Device<'a> for Renderer { self.push_clip_path_inner(&clip_path.path, clip_path.fill); } + /// PdfCraft patch (27): a nested group blending in another space can't be drawn in a pass. + fn group_blends_outside_device_cmyk(&mut self) { + if self.ink.pass.is_some() { + self.ink.unsupported = true; + } + } + + /// PdfCraft patch (27), with hayro-interpret patch (70): an isolated group that blends in + /// `DeviceCMYK` (ISO 32000-2 §11.3.4). Its contents are drawn twice on canvases of their own, + /// as cyan-magenta-yellow and as black (see [`InkState`]), and the tints those passes leave + /// are converted to RGB through the CMYK profile solid CMYK colour is drawn with. The + /// result goes into the layer the interpreter pushed for the group, which composites it + /// with the group's opacity, soft mask and blend mode as before. Declined (and so drawn as + /// before) under reading colours, inside a pass (a nested group then blends in the pass's + /// own CMYK), while erasing for a knockout group (the contents must clear, not paint), and + /// for contents a pass has no tints for. + fn draw_isolated_cmyk_group(&mut self, group: &hayro_interpret::CmykGroup<'a, '_>) -> bool { + if self.color_replacement.is_some() || self.ink.pass.is_some() || self.erasing { + return false; + } + let canvas = Rect::new(0.0, 0.0, f64::from(self.ctx.width()), f64::from(self.ctx.height())); + let area = group.device_bbox().intersect(canvas); + if !(area.width() > 0.0 && area.height() > 0.0) { + // Nothing of the group is on the canvas (a NaN box lands here too). + return true; + } + let (x0, y0) = (area.x0.floor(), area.y0.floor()); + // Within the canvas, so the sides fit its u16 ones. + let width = (area.x1.ceil() - x0) as u16; + let height = (area.y1.ceil() - y0) as u16; + if width == 0 || height == 0 { + return true; + } + if u64::from(width) * u64::from(height) > MAX_CMYK_GROUP_PIXELS { + return false; + } + + let Some(cmy) = self.ink_pass(group, InkPass::Cmy, (x0, y0), width, height) else { + return false; + }; + let Some(k) = self.ink_pass(group, InkPass::K, (x0, y0), width, height) else { + return false; + }; + + // A tint is a pass's premultiplied colour un-premultiplied and complemented. + let ink = |premul: u8, alpha: u8| { + if alpha == 0 { + return 0; + } + let straight = (u32::from(premul) * 255 + u32::from(alpha) / 2) / u32::from(alpha); + 255 - straight.min(255) as u8 + }; + let pixels = usize::from(width) * usize::from(height); + let mut tints = Vec::with_capacity(pixels * 4); + for (p, q) in cmy.data().iter().zip(k.data()) { + // Both passes draw the same shapes at the same opacities, so they cover the same + // pixels by the same amounts; each pass's own alpha un-premultiplies its colour. + tints.extend_from_slice(&[ink(p.r, p.a), ink(p.g, p.a), ink(p.b, p.a), ink(q.r, q.a)]); + } + let mut rgb = vec![0u8; pixels * 3]; + if hayro_interpret::color::cmyk_to_srgb8(&tints, &mut rgb).is_none() { + return false; + } + + let mut result = Pixmap::new(width, height); + for ((px, rgb), coverage) in result.data_mut().iter_mut().zip(rgb.chunks_exact(3)).zip(cmy.data()) { + let a = u32::from(coverage.a); + let premul = |v: u8| ((u32::from(v) * a + 127) / 255) as u8; + *px = PremulRgba8 { r: premul(rgb[0]), g: premul(rgb[1]), b: premul(rgb[2]), a: coverage.a }; + } + + // The group's own soft mask and blend mode are on its layer; what the contents set + // must not apply to the finished pixels a second time. + self.set_soft_mask(None); + self.set_blend_mode(BlendMode::Normal); + // A pattern painted earlier leaves its paint transform on the context. + self.ctx.set_paint_transform(Affine::IDENTITY); + self.draw_pixmap(Arc::new(result), ImageQuality::Low, Affine::translate((x0, y0))); + true + } + fn push_transparency_group( &mut self, opacity: f32, @@ -1708,6 +2012,15 @@ impl<'a> Device<'a> for Renderer { self.erase_groups += 1; return; } + // PdfCraft patch (26): an image or group of another pass than the current one clears + // what is under its shape instead of painting: its layer, drawn at full opacity with no + // soft mask, composites with `Compose::DestOut`. That clears the coverage of a masked + // image's or a stencil's mask too, which `knockout_image` leaves alone. + let (opacity, mask, layer_blend) = if self.knockout_pass_skips(blend_mode) { + (1.0, None, peniko::BlendMode::new(Mix::Normal, Compose::DestOut)) + } else { + (opacity, mask, convert_blend_mode(blend_mode)) + }; let settings = *self.ctx.render_settings(); let force_images_may_have_transparency = mask.is_some() || blend_mode != BlendMode::Normal @@ -1718,7 +2031,7 @@ impl<'a> Device<'a> for Renderer { .unwrap_or(false); self.ctx.push_layer( None, - Some(convert_blend_mode(blend_mode)), + Some(layer_blend), Some(opacity), // TODO: Deduplicate mask.map(|m| { @@ -1786,6 +2099,32 @@ impl<'a> Device<'a> for Renderer { self.ctx.set_blend_mode(knockout_blend_mode()); } + fn begin_knockout_pass(&mut self, mode: BlendMode) { + if self.in_knockout() && !self.erasing { + self.knockout_passes.push(KnockoutPass { + depth: self.knockout.len(), + mode, + element_mode: BlendMode::Normal, + other_modes: Vec::new(), + }); + } + } + + fn end_knockout_pass(&mut self) -> Vec { + // In an erase pass `begin_knockout_pass` started nothing; the pass at this depth belongs + // to the knockout group being erased into, and stays. + if self.erasing { + return Vec::new(); + } + let depth = self.knockout.len(); + match self.knockout_passes.last() { + Some(pass) if pass.depth == depth => { + self.knockout_passes.pop().map(|pass| pass.other_modes).unwrap_or_default() + } + _ => Vec::new(), + } + } + fn in_knockout_group(&mut self) -> bool { self.in_knockout() } @@ -1925,6 +2264,13 @@ impl<'a> Device<'a> for Renderer { } fn set_blend_mode(&mut self, blend_mode: BlendMode) { + // PdfCraft patch (26): the blend mode of the element about to paint, for its pass (an + // erase pass paints nothing, and its content's modes are not the element's). + if !self.erasing + && let Some(pass) = self.knockout_pass() + { + pass.element_mode = blend_mode; + } // PdfCraft patch (22): inside a knockout group every element replaces what it covers. let mode = if self.in_knockout() { knockout_blend_mode() @@ -2017,6 +2363,7 @@ fn draw_soft_mask(mask: &SoftMask<'_>, settings: RenderSettings, width: u16, hei in_annotations: false, knockout: Vec::new(), erasing: false, + knockout_passes: Vec::new(), erase_groups: 0, inside_pattern: false, cur_mask: None, @@ -2026,6 +2373,7 @@ fn draw_soft_mask(mask: &SoftMask<'_>, settings: RenderSettings, width: u16, hei scaler: Scaler::new(ResamplingFunction::CatmullRom), preview, image_transparency_stack: Vec::new(), + ink: InkState::default(), }; let bg_color = mask.background_color().to_rgba(); @@ -2112,11 +2460,59 @@ fn convert_fill_rule(fill_rule: FillRule) -> Fill { } } +/// PdfCraft patch (26): one blend-mode pass over a knockout group (`Device::begin_knockout_pass`). +struct KnockoutPass { + /// `knockout.len()` with the group pushed: its own elements are drawn at this depth. + depth: usize, + /// The blend mode whose elements paint in this pass. + mode: BlendMode, + /// The blend mode of the element painting now (`set_blend_mode`). + element_mode: BlendMode, + /// The other blend modes this pass's elements used, each once (at most one per mode). + other_modes: Vec, +} + +/// PdfCraft patch (26): `paint` at full opacity, for the shape it covers. +fn shape_of<'a>(paint: &Paint<'a>) -> Paint<'a> { + match paint { + Paint::Pattern(p) => match p.as_ref() { + Pattern::Shading(s) => { + let mut s = s.clone(); + s.opacity = 1.0; + Paint::Pattern(Box::new(Pattern::Shading(s))) + } + Pattern::Tiling(_) => paint.clone(), + }, + Paint::Color(_) => paint.clone(), + } +} + +/// PdfCraft patch (26): whether `paint` is a shading. +fn is_shading(paint: &Paint<'_>) -> bool { + matches!(paint, Paint::Pattern(p) if matches!(p.as_ref(), Pattern::Shading(_))) +} + /// PdfCraft patch (22): how an element of a knockout group is drawn (see `set_knockout`). fn knockout_blend_mode() -> peniko::BlendMode { peniko::BlendMode::new(Mix::Normal, Compose::Copy) } +/// PdfCraft patch (19): the part of `bbox` a shading encoded within it paints. A mesh paints +/// only around its triangles, and nothing (`None`) when none is in `bbox`; its empty extent is +/// `Rect::ZERO`, at the canvas origin, so it is tested before intersecting, or a 1 × 1 +/// transparent texture would still be painted over the whole path (310 of them cost 230 ms per +/// tile). Any other shading paints all of `bbox`. +fn mesh_area(bbox: Rect, encoded: &EncodedShadingPattern) -> Option { + let Some(extent) = encoded.extent else { + return Some(bbox); + }; + if extent.width() <= 0.0 || extent.height() <= 0.0 { + return None; + } + let area = bbox.intersect(extent.inflate(1.0, 1.0)); + (area.width() > 0.0 && area.height() > 0.0).then_some(area) +} + fn convert_blend_mode(blend_mode: BlendMode) -> peniko::BlendMode { let mix = match blend_mode { BlendMode::Normal => Mix::Normal, @@ -2214,3 +2610,28 @@ pub fn tiling_cell_pixels(scale: f32, step: f32, max_pixels: f32) -> (u16, f64) // (`exact` is), so the division is too. (pixels as u16, pixels / step) } + +#[cfg(test)] +mod snap_tests { + use super::{snap_minified_image, Affine}; + + /// The skew a snap drops is measured as the distance it moves the image's far edge, not as a + /// coefficient against the scale: a 40 000 sample wide image skewed by 4e-5 per sample moves + /// its far edge 1.6 device pixels, and must keep its full transform (#5640 review). + #[test] + fn a_skew_that_moves_a_far_edge_is_not_snapped() { + let source = (40_000, 400); + let planned = (20_000, 200); + // Upright, shrunk to half, sheared so the far end sits 1.6 pixels lower: not snapped. + let skewed = Affine::new([0.5, 4e-5, 0.0, 0.5, 0.3, 0.3]); + assert!(snap_minified_image(&skewed, source, planned).is_none()); + // A quarter turn with the same stray term along device x: not snapped either. + let turned = Affine::new([4e-5, 0.5, -0.5, 0.0, 200.3, 0.3]); + assert!(snap_minified_image(&turned, source, (20_000, 200)).is_none()); + // Rounding noise far below a hundredth of a pixel at the far edge still snaps. + let noisy = Affine::new([0.5, 1e-9, 0.0, 0.5, 0.3, 0.3]); + assert!(snap_minified_image(&noisy, source, planned).is_some()); + let turned_noisy = Affine::new([1e-9, 0.5, -0.5, 0.0, 200.3, 0.3]); + assert!(snap_minified_image(&turned_noisy, source, (20_000, 200)).is_some()); + } +} diff --git a/vendor/vello_common/src/flatten.rs b/vendor/vello_common/src/flatten.rs index be219c6c32..afacf7ecbc 100644 --- a/vendor/vello_common/src/flatten.rs +++ b/vendor/vello_common/src/flatten.rs @@ -5,7 +5,7 @@ use crate::flatten_simd::{Callback, LinePathEl}; use crate::geometry::RectU16; -use crate::kurbo::{self, Affine, PathEl, Stroke, StrokeCtx, StrokeOpts}; +use crate::kurbo::{self, Affine, BezPath, PathEl, Stroke, StrokeCtx, StrokeOpts}; use alloc::vec::Vec; use fearless_simd::{Level, Simd, dispatch}; use log::warn; @@ -220,7 +220,8 @@ pub fn fill_impl( /// See the note about open subpaths and culling on [`fill`]. pub fn stroke( level: Level, - path: impl IntoIterator, + // PdfCraft patch: `Clone` so the path can be stroked a second time. + path: impl IntoIterator + Clone, style: &Stroke, affine: Affine, line_buf: &mut Vec, @@ -235,7 +236,14 @@ pub fn stroke( .max(affine.as_coeffs()[3].abs()) .max(1.); - expand_stroke(path, style, tolerance, stroke_ctx); + expand_stroke(path.clone(), style, tolerance, stroke_ctx); + // PdfCraft patch: kurbo's stroker can put NaN into the outline of a curve that is nearly a + // line (all control points within a rounding error of the chord), and `fill` then drops the + // whole outline. Stroke it again with such curves as lines. A finite outline is unchanged. + if !stroke_ctx.output().is_finite() { + let path = near_line_curves_as_lines(path, tolerance); + expand_stroke(&path, style, tolerance, stroke_ctx); + } fill( level, stroke_ctx.output(), @@ -246,6 +254,49 @@ pub fn stroke( ); } +// PdfCraft patch. +/// `path` with each quadratic or cubic whose control points all lie within +/// `tolerance` of its chord replaced by a line to its end point. One element out per element in. +fn near_line_curves_as_lines(path: impl IntoIterator, tolerance: f64) -> BezPath { + let mut out = BezPath::new(); + let mut start = kurbo::Point::ZERO; + let mut last = start; + for el in path { + let el = match el { + PathEl::QuadTo(p1, p2) if near_segment(p1, last, p2, tolerance) => PathEl::LineTo(p2), + PathEl::CurveTo(p1, p2, p3) + if near_segment(p1, last, p3, tolerance) + && near_segment(p2, last, p3, tolerance) => + { + PathEl::LineTo(p3) + } + el => el, + }; + match el { + PathEl::MoveTo(p) => { + start = p; + last = p; + } + PathEl::LineTo(p) | PathEl::QuadTo(_, p) | PathEl::CurveTo(_, _, p) => last = p, + PathEl::ClosePath => last = start, + } + out.push(el); + } + out +} + +/// Whether `p` is within `tolerance` of the segment from `a` to `b`. False for non-finite input. +fn near_segment(p: kurbo::Point, a: kurbo::Point, b: kurbo::Point, tolerance: f64) -> bool { + let ab = b - a; + let len2 = ab.hypot2(); + let t = if len2 > 0.0 { + ((p - a).dot(ab) / len2).clamp(0.0, 1.0) + } else { + 0.0 + }; + (p - (a + ab * t)).hypot() <= tolerance +} + /// Expand a stroked path to a filled path. pub fn expand_stroke( path: impl IntoIterator, @@ -284,3 +335,74 @@ impl Callback for FlattenerCallback<'_> { } } } + +#[cfg(test)] +mod tests { + use super::{FlattenCtx, Line, TOL, expand_stroke, stroke}; + use crate::geometry::RectU16; + use crate::kurbo::{Affine, BezPath, Point, Stroke, StrokeCtx}; + use alloc::vec::Vec; + use fearless_simd::Level; + + fn stroke_lines(path: &BezPath, width: f64, ctx: &mut StrokeCtx) -> Vec { + let mut lines = Vec::new(); + stroke( + Level::baseline(), + path, + &Stroke::new(width), + Affine::IDENTITY, + &mut lines, + &mut FlattenCtx::default(), + ctx, + RectU16::new(0, 0, 200, 200), + ); + lines + } + + // An outline in an Illustrator file (#4907) has a cubic whose control points are all equal + // and 0.001 from its start. kurbo's stroker gives that curve a NaN join, which made the whole + // outline disappear. The trigger depends on these exact coordinates. + #[test] + fn a_tiny_degenerate_cubic_does_not_drop_the_stroke() { + let mut path = BezPath::new(); + path.move_to((0.0, 0.0)); + path.line_to((73.763, -0.536)); + let p = Point::new(73.764, -0.536); + path.curve_to(p, p, p); + + let mut ctx = StrokeCtx::default(); + let lines = stroke_lines(&path, 0.34, &mut ctx); + assert!(!lines.is_empty(), "the stroke was dropped"); + assert!(ctx.output().is_finite()); + let max_x = lines + .iter() + .map(|l| l.p0.x.max(l.p1.x)) + .fold(f32::MIN, f32::max); + assert!(max_x > 73.0, "the stroke ends at x = {max_x}"); + } + + #[test] + fn a_path_with_nan_is_still_dropped() { + let mut path = BezPath::new(); + path.move_to((10.0, 10.0)); + path.curve_to((f64::NAN, 10.0), (10.0, 60.0), (60.0, 60.0)); + let lines = stroke_lines(&path, 2.0, &mut StrokeCtx::default()); + assert!(lines.is_empty()); + } + + // A regression guard: it passes with or without the patch. + #[test] + fn a_finite_outline_is_stroked_as_before() { + let mut path = BezPath::new(); + path.move_to((10.0, 10.0)); + path.curve_to((60.0, 10.0), (10.0, 60.0), (60.0, 60.0)); + let style = Stroke::new(2.0); + let mut expected = StrokeCtx::default(); + expand_stroke(&path, &style, TOL, &mut expected); + + let mut ctx = StrokeCtx::default(); + let lines = stroke_lines(&path, 2.0, &mut ctx); + assert!(!lines.is_empty()); + assert_eq!(ctx.output(), expected.output()); + } +} diff --git a/vendor/vello_common/src/strip_generator.rs b/vendor/vello_common/src/strip_generator.rs index 9a8bfc237b..dcf9d4515a 100644 --- a/vendor/vello_common/src/strip_generator.rs +++ b/vendor/vello_common/src/strip_generator.rs @@ -144,7 +144,8 @@ impl StripGenerator { /// Generate the strips for a stroked path. pub fn generate_stroked_path( &mut self, - path: impl IntoIterator, + // PdfCraft patch: `Clone` for `flatten::stroke`. + path: impl IntoIterator + Clone, stroke: &Stroke, transform: Affine, aliasing_threshold: Option, diff --git a/vendor/vello_cpu/.cargo_vcs_info.json b/vendor/vello_cpu/.cargo_vcs_info.json new file mode 100644 index 0000000000..706823d53d --- /dev/null +++ b/vendor/vello_cpu/.cargo_vcs_info.json @@ -0,0 +1,6 @@ +{ + "git": { + "sha1": "875f324f21da93019cae9e8e61d4abfd69893206" + }, + "path_in_vcs": "sparse_strips/vello_cpu" +} \ No newline at end of file diff --git a/vendor/vello_cpu/CHANGELOG.md b/vendor/vello_cpu/CHANGELOG.md new file mode 100644 index 0000000000..ddb5608611 --- /dev/null +++ b/vendor/vello_cpu/CHANGELOG.md @@ -0,0 +1,205 @@ + + +# 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 + +- `render_decoration` on glyph run builders for rendering text decorations with skip-ink behavior. ([#1592][] by [@taj-p][]) +- Support for rendering VARC glyphs in text paths. ([#1594][] by [@nicoburns][], [@oscargus][]) +- Support for synthetic font emboldening in text glyph runs via `GlyphRunBuilder::font_embolden` and `glifo::FontEmbolden`. ([#1628][] by [@jrmoulton][]) + +### Changed + +- Migrated text rendering to `glifo`; rendering, glyph, and image APIs now use an explicit `Resources` object for persistent image and glyph caches. `RenderContext::render_to_buffer`, `RenderContext::render_to_pixmap`, `RenderContext::composite_to_pixmap_at_offset`, and `RenderContext::glyph_run` now take resources, and image registration moved from `RenderContext` to `Resources`. ([#1562][] by [@LaurenzV][]) +- Renamed image transparency hint APIs from `may_have_opacities` and `*_opacity_hint` to `may_have_transparency` and `*_transparency_hint`. ([#1613][] by [@upsuper][]) + +### Removed + +- Experimental `vello_api` integration and `api` module. ([#1602][] by [@waywardmonkeys][]) +- Support for recordings. + 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 + +- Rendering of rare degenerate radial gradients with undefined regions, which should render those regions transparent. ([#1529][] by [@LaurenzV][]) +- Incorrect rendering of filter layers containing nested clip layers. ([#1541][] by [@LaurenzV][]) +- x86 artifacts in bicubic image rendering caused by negative interpolated color values wrapping during SIMD `f32` to `u8` conversion. ([#1563][] by [@LaurenzV][]) +- Stroked glyph rendering when scaled glyph runs absorb the transform into the font size, preserving the expected stroke width. ([#1576][] by [@LaurenzV][]) +- COLR glyph rendering, including glyphs using non-default blend modes. ([#1584][] by [@LaurenzV][]) +- Rendering of inverted rectangles, including blurred rounded rectangles. ([#1589][] by [@LaurenzV][]) + +### Optimized + +- CPU fine rasterization performance through improved SIMD codegen. ([#1616][] by [@LaurenzV][]) + +## [0.0.7][] - 2026-03-24 + +This release has an [MSRV][] of 1.92. + +### Added + +- `composite_to_pixmap_at_offset` method to `RenderContext` for compositing at specific offsets within a larger pixmap. ([#1416][] by [@grebmeg][]) +- `ImageResolver` for resolving opaque image IDs at rasterization time. ([#1451][] by [@grebmeg][]) +- Support for image tinting. ([#1460][] by [@grebmeg][]) + +### Fixed + +- Rendering of blurred rounded rectangles with zero or very small blur standard deviations. ([#1422][] by [@tomcur][]) +- Off-by-one error in gaussian blur decimation filter. ([#1488][] by [@LaurenzV][]) +- Filter layers with zero clips. ([#1437][] by [@LaurenzV][]) + +### Optimized + +- Bilinear image sampling in the `RenderMode::OptimizeQuality` (`f32`) pipeline. ([#1343][] by [@tomcur][]) + +## [0.0.6][] - 2026-01-15 + +This release has an [MSRV][] of 1.88. + +### Added + +- Support for the "offset" filter. ([#1351] by [@waywardmonkeys]) + +### 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 image rendering for axis-aligned images. ([#1335][] by [@grebmeg][]) + +See also the [vello_hybrid 0.0.6](../vello_hybrid/CHANGELOG.md#006---2026-01-15) and [vello_common 0.0.6](../vello_common/CHANGELOG.md#006---2026-01-15) releases. + +## [0.0.5][] - 2026-01-08 + +This release has an [MSRV][] of 1.88. + +### Added + +- The `RenderContext` now has a `set_blend_mode` (and a corresponding `blend_mode` getter method) that can be used to support non-isolated blending. ([#1159][] by [@LaurenzV]) +- The `RenderContext` now contains a `push_clip_path` and `pop_clip_path` method for performing non-isolated clipping. ([#1203][] by [@LaurenzV]) +- Experimental support for image filter effects: ([#1286][] by [@grebmeg][]) + - New filter API methods on `RenderContext`: + - `set_filter_effect()` - Set a filter to be applied to subsequent drawing operations. + - `push_filter_layer()` - Create a new layer with a filter effect. + - `FilterEffect` trait providing both u8 and f32 precision variants for rendering across different backends. + - Gaussian Blur filter with configurable standard deviation and edge modes (`None`, `Mirror`, `Wrap`, `Duplicate`). + Uses an optimized decimated blur algorithm with automatic downsampling for performance. + - Drop Shadow filter with customizable offset, blur radius, and shadow color. + - Flood filter for solid color fills. +- A `set_mask` method to make it possible to mask rendered paths without inducing layer isolation. ([#1237][] by [@LaurenzV]) +- Support for conditionally disabling the u8 or f32 pipeline. ([#1294][] by [@nicoburns]) + +### Changed + +- Improved performance of rendering opaque images. ([#1327][] by [@grebmeg]) + +### Known Limitations + +- Filter effects currently support only single-primitive filters; filter graphs with multiple chained primitives are not yet supported. +- Multithreaded rendering is not supported for filter effects; filters are only applied in single-threaded mode. + +See also the [vello_hybrid 0.0.5](../vello_hybrid/CHANGELOG.md#005---2026-01-08) and [vello_common 0.0.5](../vello_common/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_common 0.0.4](../vello_common/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_common 0.0.3](../vello_common/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_common 0.0.2](../vello_common/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_common 0.0.1](../vello_common/CHANGELOG.md#001---2025-05-10) release. + +[@DJMcNab]: https://github.com/DJMcNab +[@grebmeg]: https://github.com/grebmeg +[@jrmoulton]: https://github.com/jrmoulton +[@LaurenzV]: https://github.com/LaurenzV +[@nicoburns]: https://github.com/nicoburns +[@oscargus]: https://github.com/oscargus +[@taj-p]: https://github.com/taj-p +[@tomcur]: https://github.com/tomcur +[@upsuper]: https://github.com/upsuper +[@waywardmonkeys]: https://github.com/waywardmonkeys + +[#1159]: https://github.com/linebender/vello/pull/1159 +[#1203]: https://github.com/linebender/vello/pull/1203 +[#1237]: https://github.com/linebender/vello/pull/1237 +[#1286]: https://github.com/linebender/vello/pull/1286 +[#1294]: https://github.com/linebender/vello/pull/1294 +[#1327]: https://github.com/linebender/vello/pull/1327 +[#1335]: https://github.com/linebender/vello/pull/1335 +[#1343]: https://github.com/linebender/vello/pull/1343 +[#1349]: https://github.com/linebender/vello/pull/1349 +[#1351]: https://github.com/linebender/vello/pull/1351 +[#1353]: https://github.com/linebender/vello/pull/1353 +[#1354]: https://github.com/linebender/vello/pull/1354 +[#1416]: https://github.com/linebender/vello/pull/1416 +[#1422]: https://github.com/linebender/vello/pull/1422 +[#1437]: https://github.com/linebender/vello/pull/1437 +[#1451]: https://github.com/linebender/vello/pull/1451 +[#1460]: https://github.com/linebender/vello/pull/1460 +[#1488]: https://github.com/linebender/vello/pull/1488 +[#1529]: https://github.com/linebender/vello/pull/1529 +[#1541]: https://github.com/linebender/vello/pull/1541 +[#1562]: https://github.com/linebender/vello/pull/1562 +[#1563]: https://github.com/linebender/vello/pull/1563 +[#1576]: https://github.com/linebender/vello/pull/1576 +[#1584]: https://github.com/linebender/vello/pull/1584 +[#1589]: https://github.com/linebender/vello/pull/1589 +[#1592]: https://github.com/linebender/vello/pull/1592 +[#1594]: https://github.com/linebender/vello/pull/1594 +[#1602]: https://github.com/linebender/vello/pull/1602 +[#1611]: https://github.com/linebender/vello/pull/1611 +[#1613]: https://github.com/linebender/vello/pull/1613 +[#1616]: https://github.com/linebender/vello/pull/1616 +[#1628]: https://github.com/linebender/vello/pull/1628 + +[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_cpu/Cargo.lock b/vendor/vello_cpu/Cargo.lock new file mode 100644 index 0000000000..adebc31450 --- /dev/null +++ b/vendor/vello_cpu/Cargo.lock @@ -0,0 +1,456 @@ +# 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 = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "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 = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[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 = "font-types" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b38ad915f6dadd993ced50848a8291a543bd41ca62bc10740d5e64e2ab4cfd7" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "glifo" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae9d48c6d81526ad2f9d5d6e5fddf5f6949ffbc46fcac0db0d061a6e65097019" +dependencies = [ + "bytemuck", + "core_maths", + "foldhash", + "hashbrown", + "log", + "peniko", + "png", + "skrifa", + "smallvec", + "vello_common", +] + +[[package]] +name = "guillotiere" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b17e70c989c36bad147b27a58d148c0741c51448aa5653436547323e524d0ab" +dependencies = [ + "euclid", +] + +[[package]] +name = "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 = "ordered-channel" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95be4d57809897b5a7539fc15a7dfe0e84141bc3dfaa2e9b1b27caa90acf61ab" +dependencies = [ + "crossbeam-channel", +] + +[[package]] +name = "peniko" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "839c8299360d2e998bdb106dc0a6cd71dcc5f4df51df1b620361bf50e283cca6" +dependencies = [ + "bytemuck", + "color", + "kurbo", + "linebender_resource_handle", + "smallvec", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[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 = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "read-fonts" +version = "0.39.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4ed38b89c2c77ff968c524145ad65fb010f38af5c7a224b53b81d47ac2daa81" +dependencies = [ + "bytemuck", + "core_maths", + "font-types", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "skrifa" +version = "0.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c34617370ae968efb7161bb2beb517d9084659aae19e24b89e3db25b46e4564" +dependencies = [ + "bytemuck", + "core_maths", + "read-fonts", +] + +[[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 = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3361bff7f7d82c0c496b92048db83846691f0e844cc28dee92b1c824291b55ee" +dependencies = [ + "bytemuck", + "fearless_simd", + "guillotiere", + "hashbrown", + "libm", + "log", + "peniko", + "png", + "smallvec", + "thiserror", +] + +[[package]] +name = "vello_cpu" +version = "0.0.8" +dependencies = [ + "bytemuck", + "crossbeam-channel", + "glifo", + "hashbrown", + "ordered-channel", + "png", + "rayon", + "thread_local", + "vello_common", +] diff --git a/vendor/vello_cpu/Cargo.toml b/vendor/vello_cpu/Cargo.toml new file mode 100644 index 0000000000..bce9113072 --- /dev/null +++ b/vendor/vello_cpu/Cargo.toml @@ -0,0 +1,192 @@ +# 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_cpu" +version = "0.0.8" +build = false +autolib = false +autobins = false +autoexamples = false +autotests = false +autobenches = false +description = "A CPU-based renderer for Vello, optimized for SIMD and multithreaded execution." +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", + "text", + "u8_pipeline", +] +f32_pipeline = [] +libm = [ + "vello_common/libm", + "glifo?/libm", +] +multithreading = [ + "std", + "dep:rayon", + "dep:thread_local", + "dep:ordered-channel", + "dep:crossbeam-channel", + "vello_common/multithreading", +] +png = [ + "dep:png", + "vello_common/png", + "glifo?/png", +] +std = [ + "vello_common/std", + "glifo?/std", +] +text = ["dep:glifo"] +u8_pipeline = [] + +[lib] +name = "vello_cpu" +path = "src/lib.rs" + +[[example]] +name = "basic" +path = "examples/basic.rs" +required-features = [ + "png", + "std", +] + +[[example]] +name = "clipping" +path = "examples/clipping.rs" +required-features = [ + "png", + "std", +] + +[[example]] +name = "masking" +path = "examples/masking.rs" +required-features = [ + "png", + "std", +] + +[[example]] +name = "paints" +path = "examples/paints.rs" +required-features = [ + "png", + "std", +] + +[dependencies.bytemuck] +version = "1.25.0" +features = ["derive"] + +[dependencies.crossbeam-channel] +version = "0.5.15" +optional = true + +[dependencies.glifo] +version = "0.1.0" +optional = true +default-features = false + +[dependencies.hashbrown] +version = "0.17.1" +features = ["default-hasher"] +default-features = false + +[dependencies.ordered-channel] +version = "1.2.0" +features = [ + "crossbeam-channel", + "crossbeam-channel", +] +optional = true + +[dependencies.png] +version = "0.18.1" +optional = true + +[dependencies.rayon] +version = "1.12.0" +optional = true + +[dependencies.thread_local] +version = "1.1.9" +optional = true + +[dependencies.vello_common] +version = "0.0.8" +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_cpu/Cargo.toml.orig b/vendor/vello_cpu/Cargo.toml.orig new file mode 100644 index 0000000000..534ae2db62 --- /dev/null +++ b/vendor/vello_cpu/Cargo.toml.orig @@ -0,0 +1,73 @@ +[package] +name = "vello_cpu" +# When moving past 0.0.x, also update caveats in the README +version = "0.0.8" +description = "A CPU-based renderer for Vello, optimized for SIMD and multithreaded execution." +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 } +glifo = { workspace = true, default-features = false, optional = true } +vello_common = { workspace = true } +hashbrown = { workspace = true } +png = { workspace = true, optional = true } +crossbeam-channel = { workspace = true, optional = true } +ordered-channel = { workspace = true, optional = true, features = ["crossbeam-channel"] } +rayon = { workspace = true, optional = true } +thread_local = { workspace = true, optional = true } + +[features] +# If adding new features, also add to `src/lib.rs` +default = ["std", "png", "text", "u8_pipeline"] +# Get floating point functions from the standard library (likely using your target’s libc). +std = ["vello_common/std", "glifo?/std"] +# Use floating point implementations from libm. +libm = ["vello_common/libm", "glifo?/libm"] +# Allow loading Pixmap from PNG, and drawing png glyphs. +png = ["dep:png", "vello_common/png", "glifo?/png"] +# Enable multi-threaded rendering. +multithreading = [ + "std", + "dep:rayon", + "dep:thread_local", + "dep:ordered-channel", + "dep:crossbeam-channel", + "vello_common/multithreading", +] +# Add support for text rendering +text = ["dep:glifo"] + +# Speed focussed rendering using u8 math +u8_pipeline = [] +# Quality focussed rendering using f32 math +f32_pipeline = [] + +[lints] +workspace = true + +[[example]] +name = "basic" +required-features = ["png", "std"] + +[[example]] +name = "clipping" +required-features = ["png", "std"] + +[[example]] +name = "masking" +required-features = ["png", "std"] + +[[example]] +name = "paints" +required-features = ["png", "std"] diff --git a/vendor/vello_cpu/LICENSE-APACHE b/vendor/vello_cpu/LICENSE-APACHE new file mode 100644 index 0000000000..d9a10c0d8e --- /dev/null +++ b/vendor/vello_cpu/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_cpu/LICENSE-MIT b/vendor/vello_cpu/LICENSE-MIT new file mode 100644 index 0000000000..48ff8e414b --- /dev/null +++ b/vendor/vello_cpu/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_cpu/README.md b/vendor/vello_cpu/README.md new file mode 100644 index 0000000000..2a78b88629 --- /dev/null +++ b/vendor/vello_cpu/README.md @@ -0,0 +1,188 @@ +
+ +# Vello CPU + +**CPU-based renderer** + +[![Latest published version.](https://img.shields.io/crates/v/vello_cpu.svg)](https://crates.io/crates/vello_cpu) +[![Documentation build status.](https://img.shields.io/docsrs/vello_cpu.svg)](https://docs.rs/vello_cpu) +[![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_cpu/latest/status.svg)](https://deps.rs/crate/vello_cpu) + +
+ + + + + +[`RenderContext`]: https://docs.rs/vello_cpu/latest/vello_cpu/struct.RenderContext.html +[RenderContext::set_paint]: https://docs.rs/vello_cpu/latest/vello_cpu/struct.RenderContext.html#method.set_paint +[RenderContext::fill_path]: https://docs.rs/vello_cpu/latest/vello_cpu/struct.RenderContext.html#method.fill_path +[RenderContext::stroke_path]: https://docs.rs/vello_cpu/latest/vello_cpu/struct.RenderContext.html#method.stroke_path +[RenderContext::glyph_run]: https://docs.rs/vello_cpu/latest/vello_cpu/struct.RenderContext.html#method.glyph_run +[RenderMode::OptimizeSpeed]: https://docs.rs/vello_cpu/latest/vello_cpu/enum.RenderMode.html#variant.OptimizeSpeed +[RenderMode::OptimizeQuality]: https://docs.rs/vello_cpu/latest/vello_cpu/enum.RenderMode.html#variant.OptimizeQuality +[`RenderContext::render_to_pixmap`]: https://docs.rs/vello_cpu/latest/vello_cpu/struct.RenderContext.html#method.render_to_pixmap +[`Pixmap`]: https://docs.rs/vello_cpu/latest/vello_cpu/struct.Pixmap.html + + + +Vello CPU is a 2D graphics rendering engine written in Rust, for devices with no or underpowered GPUs. + +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. + +## Usage + +To use Vello CPU, you need to: + +- Create a [`RenderContext`][], a 2D drawing context for a fixed-size target area. +- For each object in your scene: + - Set how the object will be painted, using [`set_paint`][RenderContext::set_paint]. + - Set the shape to be drawn for that object, using methods like [`fill_path`][RenderContext::fill_path], + [`stroke_path`][RenderContext::stroke_path], or [`glyph_run`][RenderContext::glyph_run]. +- Render it to an image using [`RenderContext::render_to_pixmap`][]. + +```rust +use vello_cpu::{RenderContext, Resources, Pixmap, RenderMode}; +use vello_cpu::{color::{palette::css, PremulRgba8}, kurbo::Rect}; +let width = 10; +let height = 5; +let mut context = RenderContext::new(width, height); +let mut resources = Resources::new(); +context.set_paint(css::MAGENTA); +context.fill_rect(&Rect::from_points((3., 1.), (7., 4.))); + +let mut target = Pixmap::new(width, height); +// While calling `flush` is only strictly necessary if you are rendering using +// multiple threads, it is recommended to always do this. +context.flush(); +context.render_to_pixmap(&mut resources, &mut target); + +let expected_render = b"\ + 0000000000\ + 0001111000\ + 0001111000\ + 0001111000\ + 0000000000"; +let magenta = css::MAGENTA.premultiply().to_rgba8(); +let transparent = PremulRgba8 {r: 0, g: 0, b: 0, a: 0}; +let mut result = Vec::new(); +for pixel in target.data() { + if *pixel == magenta { + result.push(b'1'); + } else if *pixel == transparent { + result.push(b'0'); + } else { + panic!("Got unexpected pixel value {pixel:?}"); + } +} +assert_eq!(&result, expected_render); +``` + +Feel free to take a look at some further +[examples](https://github.com/linebender/vello/tree/main/sparse_strips/vello_cpu/examples) +to better understand how to interact with Vello CPU's API, + +## 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`]s from PNG images. + Also required for rendering glyphs with an embedded PNG. Implies `std`. +- `multithreading`: Enable multi-threaded rendering. Implies `std`. +- `text` (enabled by default): Enables glyph rendering ([`glyph_run`][RenderContext::glyph_run]). +- `u8_pipeline` (enabled by default): Enable the u8 pipeline, for speed focused rendering using u8 math. + The `u8` pipeline will be used for [`OptimizeSpeed`][RenderMode::OptimizeSpeed], if both pipelines are enabled. + If you're using Vello CPU for application rendering, you should prefer this pipeline. +- `f32_pipeline`: Enable the `f32` pipeline, which is slower but has more accurate + results. This is espectially useful for rendering test snapshots. + The `f32` pipeline will be used for [`OptimizeQuality`][RenderMode::OptimizeQuality], if both pipelines are enabled. + +At least one of `std` and `libm` is required; `std` overrides `libm`. +At least one of `u8_pipeline` and `f32_pipeline` must be enabled. +You might choose to disable one of these pipelines if your application +won't use it, so as to reduce binary size. + +## Caveats + +Overall, Vello CPU is already very feature-rich and should be ready for +production use cases. The main caveat at the moment is that the API is +still likely to change and not stable yet. For example, we have +known plans to change the API around how image resources are used. + +Additionally, there are certain APIs that are still very much experimental, +including for example support for filters. This will be reflected in the +documentation of those APIs. + +Another caveat is that multi-threading with large thread counts +(more than 4) might give diminishing returns, especially when +making heavy use of layers and clip paths. + +## Performance + +Performance benchmarks can be found [here](https://laurenzv.github.io/vello_chart/), +As can be seen, Vello CPU achieves compelling performance on both, +aarch64 and x86 platforms. We also have SIMD optimizations for WASM SIMD, +meaning that you can expect good performance there as well. + +## Implementation + +If you want to gain a better understanding of Vello CPU and the +sparse strips paradigm, you can take a look at the [accompanying +master's thesis](https://ethz.ch/content/dam/ethz/special-interest/infk/inst-pls/plf-dam/documents/StudentProjects/MasterTheses/2025-Laurenz-Thesis.pdf) +that was written on the topic. Note that parts of the descriptions might +become outdated as the implementation changes, but it should give a good +overview nevertheless. + + +[libm]: https://crates.io/crates/libm + + + +## Minimum supported Rust Version (MSRV) + +This version of Vello CPU has been verified to compile with **Rust 1.92** and later. + +Future versions of Vello CPU 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 CPU'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 CPU 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_cpu/examples/basic.rs b/vendor/vello_cpu/examples/basic.rs new file mode 100644 index 0000000000..54dc7f368c --- /dev/null +++ b/vendor/vello_cpu/examples/basic.rs @@ -0,0 +1,165 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! This example demonstrates the most basic usage of Vello CPU, including +//! fundamental features in 2D rendering like filling, stroking and applying +//! transforms. + +use vello_cpu::color::palette::css::YELLOW; +use vello_cpu::kurbo::Affine; +use vello_cpu::{ + Level, Pixmap, RenderContext, RenderMode, RenderSettings, Resources, + color::palette::css::{BLUE, GREEN, RED}, + kurbo::{Circle, Rect, Shape}, +}; + +fn main() { + // Vello CPU is a CPU-based 2D renderer. It takes drawing commands like + // "fill a rectangle" or "stroke a triangle" as input and rasterizes + // them into a bitmap with premultiplied RGBA pixels, which can then + // be further processed (for example by converting to PNG or displaying + // the result in a window). + + // We first need to define some basic render settings. + let settings = RenderSettings { + // The `level` field indicates what SIMD level should be used. In + // the vast majority of cases, you should just use `Level::new` so that + // Vello CPU automatically uses an appropriate level that is + // available on the host system. + // + // There are very few reasons to override that value. One instance where + // it might be useful to override is for example when using Vello CPU + // to create reference images for test suites. In that case, you could + // pass `Level::fallback`, which indicates to Vello CPU that no + // platform-specific SIMD intrinsics should be used. This might be useful + // because it reduces the possibility of slight pixel differences when + // running on different platforms. + level: Level::new(), + // The number of additional threads that should be used for rendering. + // This setting only has an effect when the `multi-threading` feature + // is enabled. More threads _usually_ translates to better performance, + // but also higher CPU usage. Multi-threading can be very effective + // in situations where you need to continually redraw scenes (for example + // when rendering GUIs), but is usually less useful for one-time + // rendering operations. It should also be noted that the current + // implementation of multi-threading is still not fully optimized and + // might not work as well in certain workloads, so it's worth trying + // yourself whether it actually leads to any speedups before using it. + // + // If you enabled the multi-threading feature and leave this as 0, + // it has the exact same effect as rendering in single-threaded mode. + // According to our experiments, 2-4 threads give the best results, + // using 4+ threads might result in diminishing results, depending on + // the workload. + num_threads: 0, + // Define whether the renderer should prioritize speed or quality + // during rendering. Currently, the only difference is that + // `OptimizeSpeed` will use u8/u16 for the rasterization + // while `OptimizeQuality` uses f32. The former is much faster, but + // has the disadvantage that the overall color accuracy might be slightly + // worse due to quantization. Unless you really care about that, it is + // highly recommended to use the `OptimizeSpeed` rendering mode. + render_mode: RenderMode::OptimizeSpeed, + }; + + // Vello CPU embraces a slightly different paradigm than a lot of other 2D + // renderers. Many 2D renderers use _immediate mode rendering_, where + // you create a single `Pixmap` and then dispatch rendering commands into + // it. In Vello CPU, there are two components instead. + // + // The first component is the `RenderContext`, which can be thought of as + // a reusable buffer for dispatching rendering commands. The `reusable` + // part is important: In situations where you are executing multiple + // rendering passes at the same resolution, you should reset the context + // and reuse it instead of always creating a new one, as will be shown below. + // This is important because it allows Vello CPU to reuse existing memory + // allocations, leading to better performance. + // + // The second component is then the `Pixmap`, which simply acts as a storage + // for the raw RGBA pixels from the render context. + + // Let's start by creating a new render context with a certain + // width and height in pixels, as well as our render settings. + let mut ctx = RenderContext::new_with(100, 100, settings); + // Vello CPU needs this to track certain resources (e.g. for glyph caching) + // across multiple frames. You need to make sure that you create on such struct + // for each render context you create, and then only use it in combination with that + // specific render context. + let mut resources = Resources::new(); + + // Vello CPU uses a Postscript-like API, where you can use methods like + // `set_paint` or `set_stroke` to update an internal state, and then + // dispatch commands to fill or stroke paths, using the current state. + + // Using the `set_paint` method, we can change what color our shapes should + // be drawn with. You can use any RGBA color for that. Apart from that, you + // can also paint your shapes using gradients or patterns (see the `paints` + // example). In this case, we are setting the color to blue. + ctx.set_paint(BLUE); + // Now, we can dispatch a commands, for example to fill a rectangle with + // the given dimensions. + ctx.fill_path(&Rect::new(25.0, 25.0, 75.0, 75.0).to_path(0.1)); + + // We can then update the color to a red with 50% opacity... + ctx.set_paint(RED.with_alpha(0.5)); + // ...and draw a different rectangle that overlaps the previous one. You + // can also use the `fill_rect` convenience method for that. + ctx.fill_rect(&Rect::new(50.0, 50.0, 85.0, 85.0)); + + ctx.set_paint(GREEN); + // As mentioned, stroking is also supported. You can update the stroke + // properties using the `set_stroke` method. + ctx.stroke_path(&Circle::new((50.0, 50.0), 30.0).to_path(0.1)); + + // Let's say that we have drawn everything we wanted to now. Next, it is + // recommended that you call the `flush` method. In theory, this call + // is only necessary when using multi-threaded rendering, to signal that + // no more operations will be dispatched and the render context should be + // synchronized. If you forget to call this during multi-threaded rendering, + // the application will panic. In single-threaded rendering, nothing happens. + // + // However, it is highly recommended that you always call this. + // This way, downstream consumers of your crate that might have externally + // enabled Vello CPU's multi-threading feature won't run into panics when + // running your code. + ctx.flush(); + + // Now the second step is to copy the results of the render context into the + // pixmap. We do this by creating a new pixmap (or reusing an existing one). + // Please note that the pixmap and the render context need to have the same + // dimensions! Otherwise, the renderer will panic. + let mut pixmap_1 = Pixmap::new(100, 100); + // Now, simply extract the results from the render context into the + // pixmap. + ctx.render_to_pixmap(&mut resources, &mut pixmap_1); + + // Now you can do whatever you want with the pixmap, which provides raw + // access to the premultiplied RGBA pixels of the image. If you have enabled + // the `png` feature, you can convert it into a PNG image very easily and + // then save it to disk. + let png_1 = pixmap_1.into_png().unwrap(); + std::fs::write("example_basic1.png", png_1).unwrap(); + + // If you have another scene you want to draw at the same resolution, + // you can simply reuse the existing render context instead of creating + // a new one. + ctx.reset(); + + // Vello CPU supports arbitrary affine transformations. + ctx.set_transform(Affine::scale(3.0)); + ctx.set_paint(YELLOW); + // The rectangle will now actually have the dimensions 60x60 since we + // applied an affine transform that scales everything by 3x to the context. + ctx.fill_rect(&Rect::new(0.0, 0.0, 20.0, 20.0)); + ctx.flush(); + + // Once again, we render the results into a pixmap again. If you can, + // you can just reuse existing pixmaps (assuming they have the correct + // dimension), since all previous pixels in the pixmap will be + // discarded. In our case, we need to create a new one since our call + // to `into_png` consumed the pixmap. + let mut pixmap_2 = Pixmap::new(100, 100); + ctx.render_to_pixmap(&mut resources, &mut pixmap_2); + let png_2 = pixmap_2.into_png().unwrap(); + std::fs::write("example_basic2.png", png_2).unwrap(); +} diff --git a/vendor/vello_cpu/examples/clipping.rs b/vendor/vello_cpu/examples/clipping.rs new file mode 100644 index 0000000000..a9b4badc85 --- /dev/null +++ b/vendor/vello_cpu/examples/clipping.rs @@ -0,0 +1,134 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Applying clip paths using Vello CPU. + +use vello_cpu::Pixmap; +use vello_cpu::RenderContext; +use vello_cpu::Resources; +use vello_cpu::color::palette::css::{BLUE, RED, WHITE}; +use vello_cpu::kurbo::{Circle, Rect, Shape}; + +fn main() { + // Clip-paths are a fundamental operation in 2D rendering and allow you + // to constrain the visible areas of all subsequently drawn paths to the + // shape of another area. Vello CPU has full support for them and actually + // provides 2 different ways of using them. Below, we will explore them + // and explain their difference and when to use which one. + + let mut ctx = RenderContext::new(200, 200); + + // Two example clip shapes. They have a small overlap in the center that forms + // an ellipse. + let clip_1 = Circle::new((75.0, 75.0), 50.0).to_path(0.1); + let clip_2 = Circle::new((125.0, 75.0), 50.0).to_path(0.1); + + // Method 1: Non-isolated clipping using the `push_clip_path` and + // `pop_clip_path` methods: + { + // Let's first create a white background. + ctx.set_paint(WHITE); + ctx.fill_rect(&Rect::new(0.0, 0.0, 200.0, 200.0)); + + // As mentioned, clip paths contain the area that will be affected by + // our drawing operations. By default, drawing operations will be + // visible on the whole width/height of the render context. + + // After this first `push_clip_path`, all drawing operations will be + // constrained to the area of the first circle. + ctx.push_clip_path(&clip_1.to_path(0.1)); + // Clip paths can be nested/stacked to arbitrary depths. By + // nesting clip paths, the drawing area will be further reduced to + // the _intersection_ of all clip paths that are currently in-place. + // Thus, after this second `push_clip_path` call, only the pixels + // that lie in the intersection of both circles will be painted. + ctx.push_clip_path(&clip_2.to_path(0.1)); + ctx.set_paint(RED); + // Even though the rectangle covers the whole viewport, only the parts + // that lie in the intersection of both circles will be painted. + ctx.fill_rect(&Rect::new(0.0, 0.0, 200.0, 200.0)); + // By popping a clip path, the top clip-path on the element (in this case + // `clip_2`) will be removed. Thus, only `clip_1` remains in-place. + ctx.pop_clip_path(); + ctx.set_paint(BLUE.with_alpha(0.2)); + // This rectangle will only be constrained by the area of `clip_1`. + ctx.fill_rect(&Rect::new(0.0, 0.0, 200.0, 200.0)); + + // This is optional. You don't strictly have to pop all clip paths + // currently in-place before rasterizing to the pixmap. + ctx.pop_clip_path(); + + ctx.flush(); + + save_pixmap(&ctx, "example_clipping1"); + } + + // Method 2: Isolated clipping using the `push_clip_layer` and `pop_layer` + // methods: + { + // Overall, this method works exactly the same as the previous + // one, just that the method calls are different. Instead of + // `push_clip_path`, we have `push_clip_layer`, and instead of + // `pop_clip_path`, we have `pop_clip_layer`. + + ctx.set_paint(WHITE); + ctx.fill_rect(&Rect::new(0.0, 0.0, 200.0, 200.0)); + + ctx.push_clip_layer(&clip_1.to_path(0.1)); + ctx.push_clip_layer(&clip_2.to_path(0.1)); + ctx.set_paint(RED); + ctx.fill_rect(&Rect::new(0.0, 0.0, 200.0, 200.0)); + ctx.pop_layer(); + ctx.set_paint(BLUE.with_alpha(0.2)); + ctx.fill_rect(&Rect::new(0.0, 0.0, 200.0, 200.0)); + + // Unlike the first method, THIS PART IS NOT OPTIONAL! Before + // rasterizing, you need to make sure that all previously pushed layers + // have been popped. Otherwise, the renderer will panic. + ctx.pop_layer(); + + ctx.flush(); + save_pixmap(&ctx, "example_clipping2"); + } + + // If you inspect the above results, you will see that they visually yield + // the same result. So what is their difference and when should you use + // which one? The answer should become clearer when explaining how they + // differ conceptually. + // When creating a clip path using `push_clip_path`, every subsequent drawing + // operation will conceptually be stencil-masked through the intersection + // of all currently active clip paths before being drawn onto the screen. + // On the other hand, doing `push_clip_layer` will actually push a whole + // new isolated layer, and once you call + // `pop_layer`, the layer _as a whole_ will be clipped to the bounds of the + // paths and composited back into the previous layer. + // + // Which one of these two methods you should use depend on the imaging model + // you are trying to reflect. For example, in SVG, each group with a clip-path + // automatically requires creating a new isolated layer. In this case, the + // isolated clipping method fits the imaging model better. On the other hand, + // in PDF for example, clip paths and layer isolation are two completely + // separate concepts. Therefore, it makes much more sense to use the + // `push_clip_path` method, since you don't want to introduce an isolated + // layer each time a new clip path is added. + // + // Finally, it is also worth mentioning that according to your experiments, + // non-isolated clipping is usually faster than isolated clipping, especially + // on the CPU. Therefore, if you are still in doubt, it is recommended + // that you simply use the non-isolated method. If necessary, you can easily + // just mix the two different methods as well. + // + // Another small note: Clip paths can actually be emulated using alpha + // masks (see the masking example), so strictly speaking you don't need + // to use the specialized clipping methods to create clip paths. However, + // the clipping methods are _much faster_ than masking, and you should + // therefore always prefer using those over masking. +} + +fn save_pixmap(ctx: &RenderContext, filename: &str) { + let mut resources = Resources::new(); + let mut pixmap = Pixmap::new(ctx.width(), ctx.height()); + ctx.render_to_pixmap(&mut resources, &mut pixmap); + let png = pixmap.into_png().unwrap(); + std::fs::write(format!("{filename}.png"), png).unwrap(); +} diff --git a/vendor/vello_cpu/examples/masking.rs b/vendor/vello_cpu/examples/masking.rs new file mode 100644 index 0000000000..9f7b310138 --- /dev/null +++ b/vendor/vello_cpu/examples/masking.rs @@ -0,0 +1,100 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Using masks with Vello CPU. + +use vello_cpu::color::palette::css::{BLUE, RED, WHITE}; +use vello_cpu::kurbo::Rect; +use vello_cpu::{Mask, Pixmap, RenderContext, Resources}; + +const SIZE: u16 = 200; + +fn main() { + // Vello CPU supports applying luminance and alpha masks to your drawings. + + // First, we need to create our actual mask. There are multiple ways how + // you can get to it, in our case we are going to draw our own custom mask. + let mask = { + // In this case, we are drawing the mask ourselves. Note that the + // dimensions of the final mask need to match the dimensions of our + // original render context! + let mut mask_ctx = RenderContext::new(SIZE, SIZE); + let mut mask_resources = Resources::new(); + let mut pixmap = Pixmap::new(SIZE, SIZE); + + mask_ctx.set_paint(RED); + mask_ctx.fill_rect(&Rect::new(30.0, 30.0, 170.0, 170.0)); + mask_ctx.flush(); + mask_ctx.render_to_pixmap(&mut mask_resources, &mut pixmap); + + Mask::new_luminance(&pixmap) + }; + + // Create the main render context. + let mut ctx = RenderContext::new(SIZE, SIZE); + + // Similarly to clip paths (see the clipping example), there are two + // different ways of applying them: + // The first method is by creating a new isolated layer where the mask + // will be applied once the whole layer has been drawn and is composited + // into the backdrop. The second method is by setting the mask in the + // render context, in which case the mask will be applied to each shape + // directly before being drawn. Which method you should use once again + // depends on the imaging model you are reflecting. + + // Method 1: Non-isolated masking via `set_mask`. + { + ctx.set_paint(WHITE); + ctx.fill_rect(&Rect::new(0.0, 0.0, SIZE as f64, SIZE as f64)); + + // Once the mask is set, the mask will be applied to every path we + // are drawing individually before compositing it into the background. + ctx.set_mask(mask.clone()); + // We first apply the mask to the blue rectangle and then composite it. + ctx.set_paint(BLUE); + ctx.fill_rect(&Rect::new(20.0, 20.0, 130.0, 130.0)); + // Now, we yet again first apply the mask to the red rectangle only and + // then composite the result. + ctx.set_paint(RED); + ctx.fill_rect(&Rect::new(70.0, 70.0, 180.0, 180.0)); + // Use this method if you want to reset the mask currently in place. + ctx.reset_mask(); + + ctx.flush(); + save_pixmap(&ctx, "example_masking1"); + } + + ctx.reset(); + // Method 2: Isolated masking via `push_mask_layer`. + { + ctx.set_paint(WHITE); + ctx.fill_rect(&Rect::new(0.0, 0.0, SIZE as f64, SIZE as f64)); + + // Using this method, we first push a new isolated layer. Apart from that, + // nothing happens so far. + ctx.push_mask_layer(mask); + // Here, the blue rectangle will be drawn first. Then, the red one is drawn + // and subsequently composited on top of the blue one, + // without any special handling. + ctx.set_paint(BLUE); + ctx.fill_rect(&Rect::new(20.0, 20.0, 130.0, 130.0)); + ctx.set_paint(RED); + ctx.fill_rect(&Rect::new(70.0, 70.0, 180.0, 180.0)); + // Now, the whole layer is taken, the mask is applied to all of it + // and then composited into the background. + ctx.pop_layer(); + + ctx.flush(); + save_pixmap(&ctx, "example_masking2"); + } + + // As can be seen, the visual result of the two methods can be different! +} + +fn save_pixmap(ctx: &RenderContext, filename: &str) { + let mut resources = Resources::new(); + let mut pixmap = Pixmap::new(ctx.width(), ctx.height()); + ctx.render_to_pixmap(&mut resources, &mut pixmap); + let png = pixmap.into_png().unwrap(); + std::fs::write(format!("{filename}.png"), png).unwrap(); +} diff --git a/vendor/vello_cpu/examples/paints.rs b/vendor/vello_cpu/examples/paints.rs new file mode 100644 index 0000000000..3a764cd7e6 --- /dev/null +++ b/vendor/vello_cpu/examples/paints.rs @@ -0,0 +1,145 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Using gradients and patterns paints using Vello CPU. + +use std::sync::Arc; +use std::{io::Cursor, path::Path}; +use vello_cpu::{ + Image, ImageSource, Pixmap, RenderContext, Resources, + color::palette::css::{BLUE, CYAN, DEEP_PINK, MAGENTA, NAVY, ORANGE, PURPLE, WHITE, YELLOW}, + kurbo::{Affine, Point, Rect}, + peniko::{ + ColorStop, ColorStops, Extend, Gradient, ImageSampler, LinearGradientPosition, + RadialGradientPosition, SweepGradientPosition, + }, +}; + +fn main() { + // Apart from drawing shapes using a single color, you can also draw them + // using gradients and image patterns. + + let mut ctx = RenderContext::new(500, 500); + let base_rect = Rect::new(25.0, 25.0, 225.0, 225.0); + + // All you need to is to construct the given gradient/pattern and activate + // it by using the `set_paint` method. In this case, we are drawing a + // linear gradient. + ctx.set_paint(linear_gradient(&base_rect)); + ctx.fill_rect(&base_rect); + + // Since we are applying a transform to the render context, the radial + // gradient we are about to apply will also be affected to that. + ctx.set_transform(Affine::translate((250.0, 0.0))); + ctx.set_paint(radial_gradient(&base_rect)); + ctx.fill_rect(&base_rect); + + // Sweep gradient are also supported. + ctx.set_transform(Affine::translate((0.0, 250.0))); + ctx.set_paint(sweep_gradient(&base_rect)); + ctx.fill_rect(&base_rect); + + // Finally, we can use image patterns with different extend modes. + ctx.set_transform(Affine::translate((250.0, 250.0))); + ctx.set_paint(pattern()); + // If you want to apply an additional transform that just applies to the + // paint instead of the shape you are drawing, you can do that using the + // `set_paint_transform` method. + ctx.set_paint_transform(Affine::scale(15.0)); + ctx.fill_rect(&base_rect); + + ctx.flush(); + + save_pixmap(&ctx, "example_paint"); +} + +fn linear_gradient(rect: &Rect) -> Gradient { + Gradient { + kind: LinearGradientPosition { + start: Point::new(rect.x0, rect.y0), + end: Point::new(rect.x1, rect.y1), + } + .into(), + stops: color_stops([ + ColorStop::from((0.0, DEEP_PINK)), + ColorStop::from((0.55, ORANGE)), + ColorStop::from((1.0, YELLOW)), + ]), + extend: Extend::Pad, + ..Default::default() + } +} + +fn radial_gradient(rect: &Rect) -> Gradient { + let center = rect.center(); + #[allow(clippy::cast_possible_truncation, reason = "necessary for conversion")] + let radius = (rect.width().min(rect.height()) * 0.5) as f32; + Gradient { + kind: RadialGradientPosition { + start_center: center, + start_radius: radius * 0.2, + end_center: center, + end_radius: radius, + } + .into(), + stops: color_stops([ + ColorStop::from((0.0, CYAN)), + ColorStop::from((0.6, BLUE)), + ColorStop::from((1.0, NAVY)), + ]), + extend: Extend::Pad, + ..Default::default() + } +} + +fn sweep_gradient(rect: &Rect) -> Gradient { + let center = rect.center(); + Gradient { + kind: SweepGradientPosition { + center, + start_angle: 0.0, + end_angle: std::f32::consts::PI * 2.0, + } + .into(), + stops: color_stops([ + ColorStop::from((0.0, MAGENTA)), + ColorStop::from((0.5, PURPLE)), + ColorStop::from((1.0, WHITE)), + ]), + extend: Extend::Pad, + ..Default::default() + } +} + +fn pattern() -> Image { + // For patterns, you need to get access to a pixmap. You can either do this + // by rendering your own pixmap or converting for example a png image into + // one. + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../vello_sparse_tests/tests/assets/rgb_image_2x2.png"); + let file = std::fs::read(path).unwrap(); + let pixmap = Pixmap::from_png(Cursor::new(file)).unwrap(); + + Image { + // Note that only ImageSource::Pixmap is currently supported. Don't + // use ImageSource::OpaqueId. + image: ImageSource::Pixmap(Arc::new(pixmap)), + sampler: ImageSampler { + x_extend: Extend::Repeat, + y_extend: Extend::Repeat, + ..Default::default() + }, + } +} + +fn save_pixmap(ctx: &RenderContext, filename: &str) { + let mut resources = Resources::new(); + let mut pixmap = Pixmap::new(ctx.width(), ctx.height()); + ctx.render_to_pixmap(&mut resources, &mut pixmap); + let png = pixmap.into_png().unwrap(); + std::fs::write(format!("{filename}.png"), png).unwrap(); +} + +fn color_stops(stops: [ColorStop; 3]) -> ColorStops { + ColorStops::from(stops.as_slice()) +} diff --git a/vendor/vello_cpu/src/dispatch/mod.rs b/vendor/vello_cpu/src/dispatch/mod.rs new file mode 100644 index 0000000000..f9830f3621 --- /dev/null +++ b/vendor/vello_cpu/src/dispatch/mod.rs @@ -0,0 +1,95 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +#[cfg(feature = "multithreading")] +pub(crate) mod multi_threaded; +pub(crate) mod single_threaded; + +use crate::RenderMode; +use crate::kurbo::{Affine, BezPath, Rect, Stroke}; +use crate::peniko::{BlendMode, Fill}; +use core::fmt::Debug; +use vello_common::coarse::Wide; +use vello_common::encode::EncodedPaint; +use vello_common::filter_effects::Filter; +use vello_common::mask::Mask; +use vello_common::paint::{ImageResolver, Paint}; + +pub(crate) trait Dispatcher: Debug + Send + Sync { + fn wide(&self) -> &Wide; + fn fill_path( + &mut self, + path: &BezPath, + fill_rule: Fill, + transform: Affine, + paint: Paint, + blend_mode: BlendMode, + aliasing_threshold: Option, + mask: Option, + encoded_paints: &[EncodedPaint], + ); + fn stroke_path( + &mut self, + path: &BezPath, + stroke: &Stroke, + transform: Affine, + paint: Paint, + blend_mode: BlendMode, + aliasing_threshold: Option, + mask: Option, + encoded_paints: &[EncodedPaint], + ); + /// Fill a pixel-aligned rectangle with the current paint. + fn fill_rect_fast( + &mut self, + rect: &Rect, + paint: Paint, + blend_mode: BlendMode, + mask: Option, + encoded_paints: &[EncodedPaint], + ); + fn push_clip_path( + &mut self, + path: &BezPath, + fill_rule: Fill, + transform: Affine, + aliasing_threshold: Option, + ); + fn pop_clip_path(&mut self); + fn push_layer( + &mut self, + clip_path: Option<&BezPath>, + fill_rule: Fill, + clip_transform: Affine, + blend_mode: BlendMode, + opacity: f32, + aliasing_threshold: Option, + mask: Option, + filter: Option, + ); + fn pop_layer(&mut self); + fn reset(&mut self); + fn flush(&mut self, encoded_paints: &[EncodedPaint]); + fn rasterize( + &self, + buffer: &mut [u8], + render_mode: RenderMode, + width: u16, + height: u16, + encoded_paints: &[EncodedPaint], + image_resolver: &dyn ImageResolver, + ); + fn composite_at_offset( + &self, + buffer: &mut [u8], + width: u16, + height: u16, + dst_x: u16, + dst_y: u16, + dst_buffer_width: u16, + dst_buffer_height: u16, + render_mode: RenderMode, + encoded_paints: &[EncodedPaint], + image_resolver: &dyn ImageResolver, + ); +} diff --git a/vendor/vello_cpu/src/dispatch/multi_threaded.rs b/vendor/vello_cpu/src/dispatch/multi_threaded.rs new file mode 100644 index 0000000000..22fb125f90 --- /dev/null +++ b/vendor/vello_cpu/src/dispatch/multi_threaded.rs @@ -0,0 +1,900 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use crate::RenderMode; +use crate::dispatch::Dispatcher; +use crate::dispatch::multi_threaded::cost::{COST_THRESHOLD, estimate_render_task_cost}; +use crate::dispatch::multi_threaded::worker::Worker; +use crate::fine::{Fine, FineKernel}; +use crate::kurbo::{Affine, BezPath, PathEl, Point, Rect, Stroke}; +use crate::peniko::{BlendMode, Fill}; +use crate::region::Regions; +use alloc::boxed::Box; +use alloc::sync::Arc; +use alloc::vec; +use alloc::vec::Vec; +use core::fmt::{Debug, Formatter}; +use crossbeam_channel::TryRecvError; +use rayon::{ThreadPool, ThreadPoolBuilder}; +use std::cell::RefCell; +use std::ops::Range; +use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; +use std::sync::{Barrier, Mutex}; +use thread_local::ThreadLocal; +use vello_common::clip::ClipContext; +use vello_common::coarse::{Cmd, MODE_CPU, Wide}; +use vello_common::encode::EncodedPaint; +use vello_common::fearless_simd::{Level, Simd, dispatch}; +use vello_common::filter_effects::Filter; +use vello_common::geometry::RectU16; +use vello_common::mask::Mask; +use vello_common::paint::{ImageResolver, Paint}; +use vello_common::render_graph::RenderGraph; +use vello_common::strip::Strip; +use vello_common::strip_generator::StripGenerator; + +mod cost; +mod worker; + +type RenderTaskSender = crossbeam_channel::Sender; +type CoarseTaskSender = ordered_channel::Sender; +type CoarseTaskReceiver = ordered_channel::Receiver; + +/// A dispatcher for multi-threaded rendering. +/// +/// A small note for future contributors: Unfortunately, the logic of this dispatcher as well as +/// the lifecycle of the different fields of the dispatcher can be a bit hard to grasp. +/// The reason for this is that since we have to do a lot of communication across the thread boundary, +/// we have to work with lots of `Option` and `core::mem::take` operations, to ensure that we are +/// not needlessly cloning objects. +/// +/// The below comments will hopefully help with understanding the overall structure and lifecycles +/// a bit better. +pub(crate) struct MultiThreadedDispatcher { + /// The wide tile container. + wide: Wide, + clip_context: ClipContext, + /// The thread pool that is used for dispatching tasks. + thread_pool: ThreadPool, + allocation_group: AllocationGroup, + /// The cost of the current batch. + batch_cost: f32, + /// The sender used to dispatch new rendering tasks from the main thread. + /// + /// This field will be set once we call the `init` method. + /// This field will be set back to `None` when running `flush` to drop the value and thus + /// indicate to receivers that no more rendering tasks will be dispatched from that point onward. + task_sender: Option, + /// Contains one worker object for each thread. + /// + /// The workers will be initialized once when building the multi-threaded dispatcher via + /// `MultiThreadedDispatcher::new`. + workers: Arc>>, + /// The receiver for coarse command tasks, used to do coarse rasterization on the main thread. + /// + /// Similarly to `task_sender`, this value is set to `None` initially, and will only be set once + /// we actually call the `init` method (either when creating the dispatcher for the first time, or + /// when resetting it). + coarse_task_receiver: Option, + /// The storage for alpha values. + /// + /// Similarly to the single-threaded dispatcher, we want to be able to reuse the allocation holding + /// the alpha values across multiple runs of `reset`. However, we have the problem that during path + /// rendering, each thread needs to have its own allocation. We also need to be able to move + /// the allocation back and forth between the threads (during path rendering) and the main thread + /// (during fine rasterization). Because of this, we wrap it in this `MaybePresent` struct. + /// + /// During initialization, each thread will "take" the vector allocation out of its slot + /// (the vector has a length of `num_threads`, so each thread has a slot belonging to itself) + /// and will put it back to its slot after flushing. Then, during fine rasterization, we + /// take all slots out of the `MaybePresent` object so that we can easily access each buffer + /// when running the commands without having to go through the mutex. After fine rasterization, + /// the slots are put back into the `MaybePresent` object. + /// + alpha_storage: MaybePresent>>, + /// The task index that will be assigned to the next rendering task. + /// + /// Since we are rendering the paths on different threads, we need to make sure that they + /// come back in the right order. The `task_idx` is used to keep track of that order. + task_idx: u32, + /// The number of threads active in the thread pool. + num_threads: u16, + /// The strip generator for the main thread, used for clip path rasterization. + strip_generator: StripGenerator, + level: Level, + flushed: bool, + // So that we can reuse memory allocations across different runs. + allocations: Allocations, + /// Render graph (unused in multi-threaded, only needed for API compatibility with Wide). + render_graph: RenderGraph, +} + +impl MultiThreadedDispatcher { + pub(crate) fn new(width: u16, height: u16, num_threads: u16, level: Level) -> Self { + let wide = Wide::::new(width, height); + let thread_pool = ThreadPoolBuilder::new() + .num_threads(num_threads as usize) + .build() + .unwrap(); + let alpha_storage = MaybePresent::new(vec![vec![]; usize::from(num_threads)]); + let workers = Arc::new(ThreadLocal::new()); + + { + let thread_ids = Arc::new(AtomicU8::new(0)); + let workers = workers.clone(); + + // Create all workers once in `new`, so that later on we can just call`.get().unwrap()`. + thread_pool.spawn_broadcast(move |_| { + let thread_id = thread_ids.fetch_add(1, Ordering::SeqCst); + let worker = Worker::new(width, height, thread_id, level); + + let _ = workers.get_or(|| RefCell::new(worker)); + }); + } + + let task_idx = 0; + let batch_cost = 0.0; + let flushed = false; + + let mut dispatcher = Self { + wide, + thread_pool, + allocations: Allocations::default(), + allocation_group: AllocationGroup::default(), + batch_cost, + task_idx, + flushed, + workers, + clip_context: ClipContext::new(), + task_sender: None, + coarse_task_receiver: None, + strip_generator: StripGenerator::new(width, height, level), + level, + alpha_storage, + num_threads, + render_graph: RenderGraph::new(), + }; + + dispatcher.init(); + + dispatcher + } + + #[cfg(feature = "f32_pipeline")] + fn rasterize_f32( + &self, + buffer: &mut [u8], + width: u16, + height: u16, + encoded_paints: &[EncodedPaint], + image_resolver: &dyn ImageResolver, + ) { + use crate::fine::F32Kernel; + dispatch!(self.level, simd => self.rasterize_with::<_, F32Kernel>(simd, buffer, width, height, encoded_paints, image_resolver)); + } + + #[cfg(feature = "u8_pipeline")] + fn rasterize_u8( + &self, + buffer: &mut [u8], + width: u16, + height: u16, + encoded_paints: &[EncodedPaint], + image_resolver: &dyn ImageResolver, + ) { + use crate::fine::U8Kernel; + dispatch!(self.level, simd => self.rasterize_with::<_, U8Kernel>(simd, buffer, width, height, encoded_paints, image_resolver)); + } + + fn init(&mut self) { + let (render_task_sender, render_task_receiver) = crossbeam_channel::unbounded(); + let (coarse_task_sender, coarse_task_receiver) = ordered_channel::unbounded(); + let workers = self.workers.clone(); + let alpha_storage = self.alpha_storage.clone(); + + self.task_sender = Some(render_task_sender); + self.coarse_task_receiver = Some(coarse_task_receiver); + + // Spawn the loop for the worker threads. + self.thread_pool.spawn_broadcast(move |_| { + let render_task_receiver = render_task_receiver.clone(); + let mut coarse_task_sender = coarse_task_sender.clone(); + let worker = workers.get().unwrap(); + let mut worker = worker.borrow_mut(); + let thread_id = worker.thread_id(); + + // Take out the allocation for alphas and store it in the worker. + alpha_storage + .with_inner(|alphas| worker.init(std::mem::take(&mut alphas[thread_id as usize]))); + + while let Ok(task) = render_task_receiver.recv() { + worker.run_render_task(task, &mut coarse_task_sender); + } + + // If we reach this point, it means the `task_sender` has been dropped by the main thread + // and no more tasks are available (since we flushed). + // So we are done, and just need to place the alphas of the worker thread back into the + // vector. + + alpha_storage.with_inner(|alphas| { + alphas[thread_id as usize] = worker.finalize(); + }); + + // Then, we drop the `coarse_task_sender`. Once all worker threads have + // dropped their `coarse_task_sender`, the main thread knows that all workers are done + // and all alphas have been placed, so it's safe to proceed. + drop(coarse_task_sender); + }); + } + + fn register_task(&mut self, task: RenderTaskType) { + self.flushed = false; + if self.task_sender.is_none() { + self.init(); + } + + let cost = estimate_render_task_cost(&task, &self.allocation_group.path); + self.allocation_group.render_tasks.push(task); + self.batch_cost += cost; + + if self.batch_cost > COST_THRESHOLD { + self.flush_tasks(); + } + } + + fn flush_tasks(&mut self) { + self.send_pending_tasks(); + + self.batch_cost = 0.0; + } + + fn bump_task_idx(&mut self) -> u32 { + let idx = self.task_idx; + self.task_idx += 1; + idx + } + + fn send_pending_tasks(&mut self) { + let task_idx = self.bump_task_idx(); + let allocation_group = + std::mem::replace(&mut self.allocation_group, self.allocations.get()); + let task_sender = self.task_sender.as_mut().unwrap(); + let clip_path = self.clip_context.get().map(|c| OwnedClip { + strips: c.strips.into(), + alphas: c.alphas.into(), + bbox: c.bbox, + }); + let task = RenderTask { + idx: task_idx, + clip_path, + allocation_group, + }; + task_sender.send(task).unwrap(); + // TODO: Pass encoded_paints here to enable overdraw elimination for opaque indexed + // paints. Currently we pass an empty slice, so indexed paints render correctly but miss + // the FillHint::OpaqueImage optimization. The challenge is that encoded_paints is a + // borrowed reference that may not be valid by the time coarse processing runs asynchronously. + self.run_coarse(true, &[]); + } + + // Currently, we do coarse rasterization in two phases: + // + // The first phase is when we are still processing new draw commands from the client. After each + // command, we check whether there are already any generated strips, and if so we do coarse + // rasterization for them on the main thread. In this case, we want to abort in case there are + // no more path strips available to process. + // + // The second phase is when we are flushing, in which case even if the queue is empty, we only + // want to abort once all workers have closed the channel (and thus there won't be any more + // new strips that will be generated. + // + // This is why we have the `abort_empty`flag. + fn run_coarse(&mut self, abort_empty: bool, encoded_paints: &[EncodedPaint]) { + let result_receiver = self.coarse_task_receiver.as_mut().unwrap(); + + loop { + match result_receiver.try_recv() { + Ok(mut task) => { + let num_tasks = task.allocation_group.coarse_tasks.len(); + for cmd in task.allocation_group.coarse_tasks.drain(0..num_tasks) { + match cmd { + CoarseTaskType::RenderPath { + strips: strip_range, + paint, + blend_mode, + thread_id, + mask, + } => self.wide.generate( + &task.allocation_group.strips + [strip_range.start as usize..strip_range.end as usize], + paint.clone(), + blend_mode, + thread_id, + mask, + encoded_paints, + ), + CoarseTaskType::PushLayer { + thread_id, + clip_path, + blend_mode, + mask, + opacity, + } => { + let clip_path = clip_path.map(|strip_range| { + &task.allocation_group.strips + [strip_range.start as usize..strip_range.end as usize] + }); + + // layer_id 0 and filter None since filters aren't supported + self.wide.push_layer( + 0, + clip_path, + blend_mode, + mask, + opacity, + None, + // Transform can be IDENTITY because filters aren't supported in multi-threaded mode + Affine::IDENTITY, + &mut self.render_graph, + thread_id, + ); + } + CoarseTaskType::PopLayer => self.wide.pop_layer(&mut self.render_graph), + } + } + + // Put the allocation group back so it can be reused in future iterations! + self.allocations.put(task.allocation_group); + } + Err(e) => match e { + TryRecvError::Empty => { + if abort_empty { + return; + } + } + TryRecvError::Disconnected => return, + }, + } + } + } + + fn rasterize_with>( + &self, + simd: S, + buffer: &mut [u8], + width: u16, + height: u16, + encoded_paints: &[EncodedPaint], + image_resolver: &dyn ImageResolver, + ) { + let mut buffer = Regions::new(width, height, buffer); + let fines = ThreadLocal::new(); + let wide = &self.wide; + let alpha_slots = self.alpha_storage.take(); + + self.thread_pool.install(|| { + buffer.update_regions_par(|region| { + let x = region.x; + let y = region.y; + + let mut fine = fines + .get_or(|| RefCell::new(Fine::::new(simd))) + .borrow_mut(); + + let wtile = wide.get(x, y); + fine.set_coords(x, y); + + fine.clear(wtile.bg); + for cmd in &wtile.cmds { + let thread_idx = match cmd { + Cmd::AlphaFill(a) => Some(wide.attrs.fill[a.attrs_idx as usize].thread_idx), + Cmd::ClipStrip(a) => Some(wide.attrs.clip[a.attrs_idx as usize].thread_idx), + _ => None, + }; + + let alphas = thread_idx + .map(|i| alpha_slots[i as usize].as_slice()) + .unwrap_or(&[]); + fine.run_cmd(cmd, alphas, encoded_paints, image_resolver, &wide.attrs); + } + + fine.pack(region); + }); + }); + + // Don't forget to put back the alpha buffers, so that they can be re-used in + // the next path rendering iteration! + self.alpha_storage.init(alpha_slots); + } +} + +impl Dispatcher for MultiThreadedDispatcher { + fn wide(&self) -> &Wide { + &self.wide + } + + fn fill_path( + &mut self, + path: &BezPath, + fill_rule: Fill, + transform: Affine, + paint: Paint, + blend_mode: BlendMode, + aliasing_threshold: Option, + mask: Option, + _encoded_paints: &[EncodedPaint], + ) { + let start = self.allocation_group.path.len() as u32; + self.allocation_group.path.extend(path); + let end = self.allocation_group.path.len() as u32; + self.register_task(RenderTaskType::FillPath { + path_range: start..end, + transform, + paint, + fill_rule, + blend_mode, + aliasing_threshold, + mask, + }); + } + + fn stroke_path( + &mut self, + path: &BezPath, + stroke: &Stroke, + transform: Affine, + paint: Paint, + blend_mode: BlendMode, + aliasing_threshold: Option, + mask: Option, + _encoded_paints: &[EncodedPaint], + ) { + let start = self.allocation_group.path.len() as u32; + self.allocation_group.path.extend(path); + let end = self.allocation_group.path.len() as u32; + self.register_task(RenderTaskType::StrokePath { + path_range: start..end, + transform, + paint, + stroke: stroke.clone(), + blend_mode, + aliasing_threshold, + mask, + }); + } + + fn fill_rect_fast( + &mut self, + rect: &Rect, + paint: Paint, + blend_mode: BlendMode, + mask: Option, + _encoded_paints: &[EncodedPaint], + ) { + // For multi-threaded, fall back to path-based rendering. + // TODO: Implement optimized rect strip generation in worker threads. + let start = self.allocation_group.path.len() as u32; + self.allocation_group.path.extend([ + PathEl::MoveTo(Point::new(rect.x0, rect.y0)), + PathEl::LineTo(Point::new(rect.x1, rect.y0)), + PathEl::LineTo(Point::new(rect.x1, rect.y1)), + PathEl::LineTo(Point::new(rect.x0, rect.y1)), + PathEl::ClosePath, + ]); + let end = self.allocation_group.path.len() as u32; + self.register_task(RenderTaskType::FillPath { + path_range: start..end, + transform: Affine::IDENTITY, + paint, + fill_rule: Fill::NonZero, + blend_mode, + aliasing_threshold: None, + mask, + }); + } + + fn push_layer( + &mut self, + clip_path: Option<&BezPath>, + fill_rule: Fill, + clip_transform: Affine, + blend_mode: BlendMode, + opacity: f32, + aliasing_threshold: Option, + mask: Option, + filter: Option, + ) { + // TODO: Implement filter support in multi-threaded dispatcher. + // The single-threaded dispatcher has full support via LayerManager and render graph execution, + // but multi-threaded needs additional infrastructure for cross-thread layer coordination. + if filter.is_some() { + unimplemented!("Filter effects are not yet supported in multi-threaded rendering"); + } + + let mapped_clip = clip_path.map(|c| { + let start = self.allocation_group.path.len() as u32; + self.allocation_group.path.extend(c); + let end = self.allocation_group.path.len() as u32; + (start..end, clip_transform) + }); + + self.register_task(RenderTaskType::PushLayer { + clip_path: mapped_clip, + blend_mode, + opacity, + mask, + fill_rule, + aliasing_threshold, + }); + } + + fn pop_layer(&mut self) { + self.register_task(RenderTaskType::PopLayer); + } + + fn reset(&mut self) { + self.wide.reset(); + self.clip_context.reset(); + self.allocation_group.clear(); + self.batch_cost = 0.0; + self.task_idx = 0; + self.flushed = false; + self.task_sender = None; + self.coarse_task_receiver = None; + self.strip_generator.reset(); + self.alpha_storage.with_inner(|alphas| { + for alpha in alphas { + alpha.clear(); + } + }); + + let workers = self.workers.clone(); + // + 1 since we also wait on the main thread. + let barrier = Arc::new(Barrier::new(usize::from(self.num_threads) + 1)); + let t_barrier = barrier.clone(); + + self.thread_pool.spawn_broadcast(move |_| { + let worker = workers.get().unwrap(); + let mut borrowed = worker.borrow_mut(); + borrowed.reset(); + t_barrier.wait(); + }); + + barrier.wait(); + + self.init(); + } + + fn flush(&mut self, encoded_paints: &[EncodedPaint]) { + if self.flushed { + return; + } + + self.flush_tasks(); + let sender = core::mem::take(&mut self.task_sender); + // Note that dropping the sender will signal to the workers that no more new paths + // can arrive. + drop(sender); + self.run_coarse(false, encoded_paints); + + self.flushed = true; + } + + fn rasterize( + &self, + buffer: &mut [u8], + render_mode: RenderMode, + width: u16, + height: u16, + encoded_paints: &[EncodedPaint], + image_resolver: &dyn ImageResolver, + ) { + assert!(self.flushed, "attempted to rasterize before flushing"); + + // Only u8 pipeline enabled + #[cfg(all(feature = "u8_pipeline", not(feature = "f32_pipeline")))] + { + let _ = render_mode; + self.rasterize_u8(buffer, width, height, encoded_paints, image_resolver); + } + // Only f32 pipeline enabled + #[cfg(all(feature = "f32_pipeline", not(feature = "u8_pipeline")))] + { + let _ = render_mode; + self.rasterize_f32(buffer, width, height, encoded_paints, image_resolver); + } + + // Both pipelines enabled + #[cfg(all(feature = "f32_pipeline", feature = "u8_pipeline"))] + match render_mode { + RenderMode::OptimizeSpeed => { + self.rasterize_u8(buffer, width, height, encoded_paints, image_resolver); + } + RenderMode::OptimizeQuality => { + self.rasterize_f32(buffer, width, height, encoded_paints, image_resolver); + } + } + } + + fn composite_at_offset( + &self, + _buffer: &mut [u8], + _width: u16, + _height: u16, + _dst_x: u16, + _dst_y: u16, + _dst_buffer_width: u16, + _dst_buffer_height: u16, + _render_mode: RenderMode, + _encoded_paints: &[EncodedPaint], + _image_resolver: &dyn ImageResolver, + ) { + // TODO: Implement composite_at_offset for multi-threaded dispatcher. + unimplemented!("composite_at_offset is not implemented for multi-threaded dispatcher"); + } + + fn push_clip_path( + &mut self, + path: &BezPath, + fill_rule: Fill, + transform: Affine, + aliasing_threshold: Option, + ) { + self.flush_tasks(); + self.clip_context.push_clip( + path, + &mut self.strip_generator, + fill_rule, + transform, + aliasing_threshold, + ); + } + + fn pop_clip_path(&mut self) { + self.flush_tasks(); + self.clip_context.pop_clip(); + } +} + +impl Debug for MultiThreadedDispatcher { + fn fmt(&self, f: &mut Formatter<'_>) -> core::fmt::Result { + f.write_str("MultiThreadedDispatcher { .. }") + } +} + +#[derive(Debug)] +pub(crate) struct OwnedClip { + strips: Box<[Strip]>, + alphas: Box<[u8]>, + + /// A coarse bounding box of the clip path in pixel coordinates. + /// + /// These bounds have already been intersected with the viewport. + bbox: RectU16, +} + +/// A structure that allows storing and fetching existing allocations. +struct AllocationManager { + entries: Vec>, +} + +impl AllocationManager { + /// Get a new vector allocation. + /// + /// The vector is guaranteed to have been cleared before. + fn get(&mut self) -> Vec { + self.entries.pop().unwrap_or_default() + } + + /// Insert a new allocation in the store. + fn put(&mut self, mut allocation: Vec) { + allocation.clear(); + self.entries.push(allocation); + } +} + +impl Default for AllocationManager { + fn default() -> Self { + Self { entries: vec![] } + } +} + +/// A structure to keep track of allocations that will be done while rendering with +/// multi-threading. +#[derive(Default)] +struct Allocations { + /// The render tasks of a batch. They will be filled by the main thread as new fill/stroke commands + /// come in and consumed by worker threads as they process them. + render_tasks: AllocationManager, + /// The path store of a batch. It will be filled by the main thread as new commands come in + /// and be used by the worker thread to generate the strips of a path. + paths: AllocationManager, + /// Stores allocations that are used by the worker thread to produce strips. They will be + /// sent back to the main thread which then uses them for coarse rasterization. + strips: AllocationManager, + /// The coarse tasks produced by a worker thread, which will be processed by the main thread. + coarse_tasks: AllocationManager, +} + +impl Allocations { + /// Return a new allocation group. + /// + /// The group is guaranteed to have been cleared. + fn get(&mut self) -> AllocationGroup { + let render_tasks = self.render_tasks.get(); + let path = self.paths.get(); + let strips = self.strips.get(); + let coarse_tasks = self.coarse_tasks.get(); + + AllocationGroup { + path, + render_tasks, + coarse_tasks, + strips, + } + } + + fn put(&mut self, allocation: AllocationGroup) { + self.render_tasks.put(allocation.render_tasks); + self.paths.put(allocation.path); + self.strips.put(allocation.strips); + self.coarse_tasks.put(allocation.coarse_tasks); + } +} + +#[derive(Default, Debug)] +pub(crate) struct AllocationGroup { + pub(crate) path: Vec, + pub(crate) render_tasks: Vec, + pub(crate) strips: Vec, + pub(crate) coarse_tasks: Vec, +} + +impl AllocationGroup { + fn clear(&mut self) { + self.path.clear(); + self.render_tasks.clear(); + self.strips.clear(); + self.coarse_tasks.clear(); + } +} + +#[derive(Debug)] +pub(crate) struct RenderTask { + pub(crate) idx: u32, + pub(crate) clip_path: Option, + pub(crate) allocation_group: AllocationGroup, +} + +#[derive(Debug, Clone)] +pub(crate) enum RenderTaskType { + FillPath { + path_range: Range, + transform: Affine, + paint: Paint, + fill_rule: Fill, + blend_mode: BlendMode, + aliasing_threshold: Option, + mask: Option, + }, + StrokePath { + path_range: Range, + transform: Affine, + paint: Paint, + stroke: Stroke, + blend_mode: BlendMode, + aliasing_threshold: Option, + mask: Option, + }, + PushLayer { + clip_path: Option<(Range, Affine)>, + blend_mode: BlendMode, + opacity: f32, + mask: Option, + fill_rule: Fill, + aliasing_threshold: Option, + }, + PopLayer, +} + +pub(crate) struct CoarseTask { + allocation_group: AllocationGroup, +} + +#[derive(Debug)] +pub(crate) enum CoarseTaskType { + RenderPath { + thread_id: u8, + strips: Range, + blend_mode: BlendMode, + paint: Paint, + mask: Option, + }, + PushLayer { + thread_id: u8, + clip_path: Option>, + blend_mode: BlendMode, + mask: Option, + opacity: f32, + }, + PopLayer, +} + +/// An object that might hold a certain value (behind a mutex), and panics if we attempt +/// to access it when it's not initialized. +#[derive(Clone)] +pub(crate) struct MaybePresent { + present: Arc, + value: Arc>, +} + +impl MaybePresent { + pub(crate) fn new(val: T) -> Self { + Self { + present: Arc::new(AtomicBool::new(true)), + value: Arc::new(Mutex::new(val)), + } + } + + pub(crate) fn init(&self, value: T) { + let mut locked = self.value.lock().unwrap(); + *locked = value; + self.present.store(true, Ordering::SeqCst); + } + + pub(crate) fn with_inner(&self, mut func: impl FnMut(&mut T)) { + assert!( + self.present.load(Ordering::SeqCst), + "Tried to access `MaybePresent` before initialization." + ); + + let mut lock = self.value.lock().unwrap(); + func(&mut lock); + } + + pub(crate) fn take(&self) -> T { + assert!( + self.present.load(Ordering::SeqCst), + "Tried to access `MaybePresent` before initialization." + ); + + let mut locked = self.value.lock().unwrap(); + self.present.store(false, Ordering::SeqCst); + std::mem::take(&mut *locked) + } +} + +#[cfg(test)] +mod tests { + use crate::Level; + use crate::color::palette::css::BLUE; + use crate::dispatch::Dispatcher; + use crate::dispatch::multi_threaded::MultiThreadedDispatcher; + use crate::kurbo::{Affine, Rect, Shape}; + use crate::peniko::{BlendMode, Fill}; + use vello_common::paint::{Paint, PremulColor}; + + /// Ensure we don't cause a memory leak. + #[test] + fn allocations() { + let mut dispatcher = MultiThreadedDispatcher::new(100, 100, 4, Level::new()); + for _ in 0..20 { + dispatcher.fill_path( + &Rect::new(0.0, 0.0, 50.0, 50.0).to_path(0.1), + Fill::NonZero, + Affine::IDENTITY, + Paint::Solid(PremulColor::from_alpha_color(BLUE)), + BlendMode::default(), + None, + None, + &[], + ); + dispatcher.flush(&[]); + } + + assert_eq!(dispatcher.allocations.paths.entries.len(), 1); + assert_eq!(dispatcher.allocations.strips.entries.len(), 1); + assert_eq!(dispatcher.allocations.render_tasks.entries.len(), 1); + assert_eq!(dispatcher.allocations.coarse_tasks.entries.len(), 1); + } +} diff --git a/vendor/vello_cpu/src/dispatch/multi_threaded/cost.rs b/vendor/vello_cpu/src/dispatch/multi_threaded/cost.rs new file mode 100644 index 0000000000..2c84a75523 --- /dev/null +++ b/vendor/vello_cpu/src/dispatch/multi_threaded/cost.rs @@ -0,0 +1,130 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +/// There is not much science behind this constant. It was instead determined by doing profiling +/// and finding a constant that seems to strike a reasonable trade-off between not causing too +/// big batch sizes +pub(crate) const COST_THRESHOLD: f32 = 250.0; + +use crate::dispatch::multi_threaded::RenderTaskType; +use crate::kurbo::{Affine, PathEl, PathSeg, Point, segments}; + +/// Try to estimate the cost of the render task. +pub(crate) fn estimate_render_task_cost(task: &RenderTaskType, paths: &[PathEl]) -> f32 { + const LAYER_COST: f32 = 10.0; + + match task { + RenderTaskType::FillPath { + path_range, + transform, + .. + } => { + let path = &paths[path_range.start as usize..path_range.end as usize]; + estimate_path_cost(segments(path.iter().copied()), *transform, false) + } + RenderTaskType::StrokePath { + path_range, + transform, + .. + } => { + let path = &paths[path_range.start as usize..path_range.end as usize]; + estimate_path_cost(segments(path.iter().copied()), *transform, true) + } + RenderTaskType::PushLayer { clip_path, .. } => { + LAYER_COST + + clip_path + .as_ref() + .map(|(path_range, transform)| { + let path = &paths[path_range.start as usize..path_range.end as usize]; + estimate_path_cost(segments(path.iter().copied()), *transform, false) + }) + .unwrap_or(0.0) + } + RenderTaskType::PopLayer => LAYER_COST, + } +} + +/// Try to estimate an (admittedly somewhat abstract) "path cost". +/// +/// The main point here is that when sending paths to a thread to convert them into sparse strip +/// representation, we might want to batch them. This is especially the case for small, line-only +/// geometries, where handling each path separately would lead to a huge overhead. +/// +/// Because of this, before rendering a path, we try to estimate a very rough cost based on +/// the following attributes that (usually) have an impact on rendering times: +/// +/// - Number of line segments (more line segments -> more work during strip rendering). +/// - Number of curve segments (same as line segments, plus we need to flatten them first). +/// - Path length (if the path is longer, the covered area is _likely_ to also be larger). However, +/// the path length usually grows much faster than the render time, so we only apply a very small +/// fractional value. +/// - Strokes (if we are stroking a path, there is even more overhead for stroke expansion before doing +/// flattening and strip rendering). +pub(crate) fn estimate_path_cost( + path: impl IntoIterator, + transform: Affine, + is_stroke: bool, +) -> f32 { + let cost_data = PathCostData::new(path, transform); + + // Once again, those constants were not determined "scientifically" in any way, but + // are instead based on intuition as well as a number of experiments. + const CURVE_MULTIPLIER: f32 = 2.5; + const STROKE_MULTIPLIER: f32 = 1.5; + + let mut cost = cost_data.num_line_segments as f32; + cost += cost_data.num_curve_segments as f32 * CURVE_MULTIPLIER; + cost += cost * (cost_data.path_length as f32 / 1024.0); + + cost *= if is_stroke { STROKE_MULTIPLIER } else { 1.0 }; + cost +} + +struct PathCostData { + num_line_segments: u64, + num_curve_segments: u64, + path_length: f64, +} + +impl PathCostData { + fn new(path: impl IntoIterator, transform: Affine) -> Self { + let mut num_line_segments = 0; + let mut num_curve_segments = 0; + let mut path_length = 0.0; + + let mut register_path_length = |mut p0: Point, mut p1: Point| { + p0 = transform * p0; + p1 = transform * p1; + // We don't sqrt here because it's too expensive, we just want a rough estimate. + let dx = (p1.x - p0.x).abs(); + let dy = (p1.y - p0.y).abs(); + path_length += dx + dy; + }; + + for seg in path.into_iter() { + match seg { + PathSeg::Line(l) => { + num_line_segments += 1; + + register_path_length(l.p0, l.p1); + } + PathSeg::Quad(q) => { + num_curve_segments += 1; + + register_path_length(q.p0, q.p2); + } + PathSeg::Cubic(c) => { + num_curve_segments += 1; + + register_path_length(c.p0, c.p3); + } + } + } + + Self { + num_line_segments, + num_curve_segments, + path_length, + } + } +} diff --git a/vendor/vello_cpu/src/dispatch/multi_threaded/worker.rs b/vendor/vello_cpu/src/dispatch/multi_threaded/worker.rs new file mode 100644 index 0000000000..2a4737ca63 --- /dev/null +++ b/vendor/vello_cpu/src/dispatch/multi_threaded/worker.rs @@ -0,0 +1,204 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use crate::Level; +use crate::dispatch::multi_threaded::{ + CoarseTask, CoarseTaskSender, CoarseTaskType, RenderTask, RenderTaskType, +}; +use std::vec::Vec; +use vello_common::clip::PathDataRef; +use vello_common::strip_generator::{GenerationMode, StripGenerator, StripStorage}; + +#[derive(Debug)] +pub(crate) struct Worker { + strip_generator: StripGenerator, + strip_storage: StripStorage, + thread_id: u8, +} + +impl Worker { + pub(crate) fn new(width: u16, height: u16, thread_id: u8, level: Level) -> Self { + let strip_generator = StripGenerator::new(width, height, level); + let strip_storage = StripStorage::default(); + + Self { + strip_generator, + strip_storage, + thread_id, + } + } + + pub(crate) fn init(&mut self, alphas: Vec) { + self.strip_storage.alphas = alphas; + } + + pub(crate) fn thread_id(&self) -> u8 { + self.thread_id + } + + pub(crate) fn reset(&mut self) { + self.strip_generator.reset(); + } + + pub(crate) fn run_render_task( + &mut self, + mut render_task: RenderTask, + result_sender: &mut CoarseTaskSender, + ) { + let num_tasks = render_task.allocation_group.render_tasks.len(); + self.strip_storage.strips.clear(); + self.strip_storage + .set_generation_mode(GenerationMode::Append); + let task_idx = render_task.idx; + let path_clip = render_task.clip_path.as_ref().map(|c| PathDataRef { + strips: c.strips.as_ref(), + alphas: c.alphas.as_ref(), + bbox: c.bbox, + }); + + for task in render_task + .allocation_group + .render_tasks + .drain(0..num_tasks) + { + match task { + RenderTaskType::FillPath { + path_range, + transform, + paint, + fill_rule, + blend_mode, + aliasing_threshold, + mask, + } => { + let start = self.strip_storage.strips.len() as u32; + let path = &render_task.allocation_group.path + [path_range.start as usize..path_range.end as usize]; + + self.strip_generator.generate_filled_path( + path.iter().copied(), + fill_rule, + transform, + aliasing_threshold, + &mut self.strip_storage, + path_clip, + ); + let end = self.strip_storage.strips.len() as u32; + + let coarse_command = CoarseTaskType::RenderPath { + thread_id: self.thread_id, + strips: start..end, + blend_mode, + paint, + mask, + }; + + render_task + .allocation_group + .coarse_tasks + .push(coarse_command); + } + RenderTaskType::StrokePath { + path_range, + transform, + paint, + blend_mode, + stroke, + aliasing_threshold, + mask, + } => { + let start = self.strip_storage.strips.len() as u32; + let path = &render_task.allocation_group.path + [path_range.start as usize..path_range.end as usize]; + + self.strip_generator.generate_stroked_path( + path.iter().copied(), + &stroke, + transform, + aliasing_threshold, + &mut self.strip_storage, + path_clip, + ); + let end = self.strip_storage.strips.len() as u32; + + let coarse_command = CoarseTaskType::RenderPath { + thread_id: self.thread_id, + strips: start..end, + blend_mode, + paint, + mask, + }; + + render_task + .allocation_group + .coarse_tasks + .push(coarse_command); + } + RenderTaskType::PushLayer { + clip_path, + blend_mode, + opacity, + mask, + fill_rule, + aliasing_threshold, + } => { + let clip = if let Some((path_range, transform)) = clip_path { + let start = self.strip_storage.strips.len() as u32; + let path = &render_task.allocation_group.path + [path_range.start as usize..path_range.end as usize]; + + self.strip_generator.generate_filled_path( + path.iter().copied(), + fill_rule, + transform, + aliasing_threshold, + &mut self.strip_storage, + path_clip, + ); + + let end = self.strip_storage.strips.len() as u32; + + Some(start..end) + } else { + None + }; + + let coarse_command = CoarseTaskType::PushLayer { + thread_id: self.thread_id, + clip_path: clip, + blend_mode, + mask, + opacity, + }; + + render_task + .allocation_group + .coarse_tasks + .push(coarse_command); + } + RenderTaskType::PopLayer => { + render_task + .allocation_group + .coarse_tasks + .push(CoarseTaskType::PopLayer); + } + } + } + + let taken_strips = std::mem::replace( + &mut self.strip_storage.strips, + render_task.allocation_group.strips, + ); + render_task.allocation_group.strips = taken_strips; + + let task = CoarseTask { + allocation_group: render_task.allocation_group, + }; + + result_sender.send(task_idx as usize, task).unwrap(); + } + + pub(crate) fn finalize(&mut self) -> Vec { + std::mem::take(&mut self.strip_storage.alphas) + } +} diff --git a/vendor/vello_cpu/src/dispatch/single_threaded.rs b/vendor/vello_cpu/src/dispatch/single_threaded.rs new file mode 100644 index 0000000000..2a08d4b1b2 --- /dev/null +++ b/vendor/vello_cpu/src/dispatch/single_threaded.rs @@ -0,0 +1,922 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use crate::RenderMode; +use crate::dispatch::Dispatcher; +use crate::fine::{Fine, FineKernel}; +use crate::kurbo::{Affine, BezPath, Rect, Stroke}; +use crate::layer_manager::LayerManager; +use crate::peniko::{BlendMode, Fill}; +use crate::region::Regions; +use vello_common::clip::ClipContext; +use vello_common::coarse::{Cmd, LayerKind, MODE_CPU, Wide, WideTilesBbox}; +use vello_common::color::palette::css::TRANSPARENT; +use vello_common::encode::EncodedPaint; +use vello_common::fearless_simd::{Level, Simd}; +use vello_common::filter_effects::Filter; +use vello_common::mask::Mask; +use vello_common::paint::{ImageResolver, Paint, PremulColor}; +use vello_common::pixmap::Pixmap; +use vello_common::render_graph::{RenderGraph, RenderNodeKind}; +use vello_common::strip_generator::{StripGenerator, StripStorage}; + +/// Single-threaded implementation of the rendering dispatcher. +/// +/// This dispatcher handles the entire rendering pipeline on a single thread, +/// including path rasterization, layer composition, and filter effects. +/// It maintains the coarse tile grid (`Wide`), strip generation for paths, +/// and the render graph for managing layer dependencies and filter effects. +#[derive(Debug)] +pub(crate) struct SingleThreadedDispatcher { + /// Coarse tile grid containing rendering commands for each wide tile. + wide: Wide, + /// Clip context for managing non-isolated clipping. + clip_context: ClipContext, + /// Generator for converting paths into coverage strips. + strip_generator: StripGenerator, + /// Storage for alpha coverage data from strip generation. + strip_storage: StripStorage, + /// SIMD level for fearless SIMD dispatch. + level: Level, + /// Counter for generating unique layer IDs. + layer_id_next: u32, + /// Dependency graph tracking layer relationships and filter effects. + render_graph: RenderGraph, +} + +impl SingleThreadedDispatcher { + /// Creates a new single-threaded dispatcher for the given dimensions. + /// + /// # Arguments + /// * `width` - Width of the rendering surface in pixels. + /// * `height` - Height of the rendering surface in pixels. + /// * `level` - SIMD level to use for rasterization. + /// + /// # Notes + /// The root layer (`layer_id` 0) is created immediately and must be node 0 + /// in the render graph for proper rendering order. + pub(crate) fn new(width: u16, height: u16, level: Level) -> Self { + let wide = Wide::::new(width, height); + let strip_generator = StripGenerator::new(width, height, level); + let clip_context = ClipContext::new(); + let strip_storage = StripStorage::default(); + let mut render_graph = RenderGraph::new(); + + // Create root node (layer_id 0) as the first node (will be node 0). + // This ensures the root layer is always rendered last in the execution order. + let wtile_bbox = WideTilesBbox::new(0, 0, wide.width_tiles(), wide.height_tiles()); + let root_node = render_graph.add_node(RenderNodeKind::RootLayer { + layer_id: 0, + wtile_bbox, + }); + assert_eq!(root_node, 0, "Root node must be node 0"); + + Self { + wide, + clip_context, + strip_generator, + strip_storage, + level, + layer_id_next: 0, + render_graph, + } + } + + /// Rasterizes the scene using f32 precision (high quality). + /// + /// This dispatches to the appropriate SIMD implementation based on the + /// configured level, using f32 for intermediate calculations. + #[cfg(feature = "f32_pipeline")] + fn rasterize_f32( + &self, + buffer: &mut [u8], + width: u16, + height: u16, + encoded_paints: &[EncodedPaint], + image_resolver: &dyn ImageResolver, + ) { + use crate::fine::F32Kernel; + use vello_common::fearless_simd::dispatch; + dispatch!(self.level, simd => self.rasterize_with::<_, F32Kernel>(simd, buffer, width, height, encoded_paints, image_resolver)); + } + + /// Rasterizes the scene using u8 precision (fast). + /// + /// This dispatches to the appropriate SIMD implementation based on the + /// configured level, using u8 for intermediate calculations to maximize speed. + #[cfg(feature = "u8_pipeline")] + fn rasterize_u8( + &self, + buffer: &mut [u8], + width: u16, + height: u16, + encoded_paints: &[EncodedPaint], + image_resolver: &dyn ImageResolver, + ) { + use crate::fine::U8Kernel; + use vello_common::fearless_simd::dispatch; + dispatch!(self.level, simd => self.rasterize_with::<_, U8Kernel>(simd, buffer, width, height, encoded_paints, image_resolver)); + } + + /// Core rasterization dispatcher that chooses between simple and filter-aware paths. + /// + /// # Type Parameters + /// * `S` - SIMD implementation to use. + /// * `F` - Fine rasterization kernel (determines precision). + /// + /// If the scene contains filter effects, uses the filter-aware path which maintains + /// intermediate layer buffers. Otherwise, uses the simpler direct rasterization path. + fn rasterize_with>( + &self, + simd: S, + buffer: &mut [u8], + width: u16, + height: u16, + encoded_paints: &[EncodedPaint], + image_resolver: &dyn ImageResolver, + ) { + let mut layer_manager = LayerManager::new(); + + if self.has_filters() { + // Use filter-aware path that maintains layer buffers for filter effects. + self.rasterize_with_filters::( + simd, + buffer, + width, + height, + encoded_paints, + image_resolver, + &mut layer_manager, + ); + } else { + // Use simple direct rasterization for scenes without filters. + self.rasterize_simple::( + simd, + buffer, + width, + height, + encoded_paints, + image_resolver, + ); + } + } + + /// Rasterizes a scene with filter effects using dependency-ordered execution. + /// + /// This processes the render graph in topological order, ensuring that filtered + /// layers are rendered into intermediate buffers before being composed. Each + /// filter layer is rendered to its own pixmap, the filter is applied, and then + /// the result is stored in the layer manager for use by dependent layers. + /// + /// # Render Graph Execution + /// - `FilterLayer` nodes: Render to intermediate buffer, apply filter, store result. + /// - `RootLayer` node: Final composition to output buffer. + fn rasterize_with_filters>( + &self, + simd: S, + buffer: &mut [u8], + width: u16, + height: u16, + encoded_paints: &[EncodedPaint], + image_resolver: &dyn ImageResolver, + layer_manager: &mut LayerManager, + ) { + let mut fine = Fine::::new(simd); + + // Process nodes in dependency order (filtered layers before their consumers). + for node_id in self.render_graph.execution_order() { + let node = &self.render_graph.nodes[node_id]; + + match &node.kind { + RenderNodeKind::FilterLayer { + layer_id, + filter, + wtile_bbox, + transform, + } => { + // Allocate intermediate buffer for this filtered layer. + let bbox_width = wtile_bbox.width_px(); + let bbox_height = wtile_bbox.height_px(); + let mut pixmap = Pixmap::new(bbox_width, bbox_height); + // TODO: Re-use this allocation by adding a .configure() or similar method + // to avoid allocating the internal Vec on every filtered layer. + let mut regions = + Regions::new(bbox_width, bbox_height, pixmap.data_as_u8_slice_mut()); + + // Render each tile in the layer's bounding box. + regions.update_regions(|region| { + // Convert region-local coords to global wtile coords. + let x = wtile_bbox.x0() + region.x; + let y = wtile_bbox.y0() + region.y; + + self.process_layer_tile( + &mut fine, + x, + y, + *layer_id, + PremulColor::from_alpha_color(TRANSPARENT), + layer_manager, + encoded_paints, + image_resolver, + ); + + debug_assert_eq!( + fine.blend_buf.len(), + 1, + "blend buffer should contain exactly one layer after tile processing" + ); + + fine.pack(region); + }); + + // Apply the filter effect to the completed layer. + fine.filter_layer(&mut pixmap, filter, layer_manager, *transform); + + // Save the filtered pixmap to disk for debugging. + // #[cfg(all(debug_assertions, feature = "std", feature = "png"))] + // save_filtered_layer_debug(&pixmap, *layer_id); + + // Store the filtered result for use by dependent layers. + layer_manager.register_layer(*layer_id, *wtile_bbox, pixmap); + } + RenderNodeKind::RootLayer { + layer_id, + wtile_bbox: _, + } => { + // Final composition directly to output buffer. + let mut regions = Regions::new(width, height, buffer); + regions.update_regions(|region| { + // Use the background color from the wide tile. + let bg = self.wide.get(region.x, region.y).bg; + self.process_layer_tile( + &mut fine, + region.x, + region.y, + *layer_id, + bg, + layer_manager, + encoded_paints, + image_resolver, + ); + + debug_assert_eq!( + fine.blend_buf.len(), + 1, + "blend buffer should contain exactly one layer after tile processing" + ); + + fine.pack(region); + }); + } + } + } + } + + /// Processes all rendering commands for a single layer within a specific tile. + /// + /// This handles the complex logic of composing filtered layers by: + /// 1. Running normal rendering commands in sequence. + /// 2. When encountering a filtered layer reference, compositing its pre-rendered + /// content from the layer manager. + /// 3. Skipping the filtered layer's internal commands (already rendered separately). + /// + /// # Arguments + /// * `fine` - The fine rasterizer instance. + /// * `x`, `y` - Wide tile coordinates. + /// * `layer_id` - The layer being processed. + /// * `clear_color` - Initial color for the tile. + /// * `layer_manager` - Storage for filtered layer buffers. + /// * `encoded_paints` - Paint definitions for the scene. + /// * `image_resolver` - Resolver for looking up opaque image IDs. + fn process_layer_tile>( + &self, + fine: &mut Fine, + x: u16, + y: u16, + layer_id: u32, + clear_color: PremulColor, + layer_manager: &mut LayerManager, + encoded_paints: &[EncodedPaint], + image_resolver: &dyn ImageResolver, + ) { + let wtile = &self.wide.get(x, y); + fine.set_coords(x, y); + fine.clear(clear_color); + + // Process all commands in this layer's render range. + // It can happen that the layer has no associated ranges in this wide tile in + // case they have been cleared by setting a new wide tile background, for example + // when filling a full-tile opaque solid color. + let Some(ranges) = wtile.layer_cmd_ranges.get(&layer_id) else { + return; + }; + + let mut cmd_idx = ranges.render_range.start; + while cmd_idx < ranges.render_range.end { + let cmd: &Cmd = &wtile.cmds[cmd_idx]; + + fine.run_cmd( + cmd, + &self.strip_storage.alphas, + encoded_paints, + image_resolver, + &self.wide.attrs, + ); + + // Special handling for filtered layer composition. + // Filtered layers have already been rendered and stored in layer_manager. + // Here we composite them into the current buffer, with special handling for clipping. + if let Cmd::PushBuf(LayerKind::Filtered(child_layer_id), _) = cmd { + // Unlike above, the unwrap is safe here because as long as the filtered layer + // is referenced in the wide tile, it must have associated layer ranges. + let filtered_ranges = wtile.layer_cmd_ranges.get(child_layer_id).unwrap(); + + // Check what comes after the filtered layer push to determine clipping state + match wtile.cmds.get(cmd_idx + 1) { + // Zero-clip region: tile is completely outside the clip path. + // The layer was already rendered for filtering, but we skip compositing + // since this tile is entirely clipped out. + // (PushZeroClip only appears for clipped filter layers) + // See https://github.com/linebender/vello/pull/1541/ for why we + // add the ID check. + Some(Cmd::PushZeroClip(id)) if *id == *child_layer_id => { + // If we have a zero-clip, it means that the whole layer should not be drawn. + // Therefore, we want to skip to the very end so that only `PopBuf` will + // be run. Therefore, we jump to `filtered_ranges.full_range.end - 1`. + cmd_idx = filtered_ranges.full_range.end - 1; + continue; + } + + // Partial clip: push the clip buffer, then composite the filtered layer + Some(Cmd::PushBuf(LayerKind::Clip(id), _)) if *id == *child_layer_id => { + fine.run_cmd( + &wtile.cmds[cmd_idx + 1], + &self.strip_storage.alphas, + encoded_paints, + image_resolver, + &self.wide.attrs, + ); + cmd_idx += 1; + + if let Some(mut region) = + layer_manager.layer_tile_region_mut(*child_layer_id, x, y) + { + fine.unpack(&mut region); + } + } + + // No clip or fully inside clip: composite the filtered layer directly + _ => { + if let Some(mut region) = + layer_manager.layer_tile_region_mut(*child_layer_id, x, y) + { + fine.unpack(&mut region); + } + } + } + + // Skip past the filtered layer's internal commands, as they were already + // rendered when the FilterLayer node was processed earlier. + cmd_idx = filtered_ranges.render_range.end.max(cmd_idx + 1); + } else { + cmd_idx += 1; + } + } + } + + /// Simple rasterization path for scenes without filter effects. + /// + /// This directly processes each tile's commands without maintaining intermediate + /// layer buffers. All rendering happens in a single pass directly to the output buffer. + /// This is more efficient than the filter-aware path when no filters are present. + fn rasterize_simple>( + &self, + simd: S, + buffer: &mut [u8], + width: u16, + height: u16, + encoded_paints: &[EncodedPaint], + image_resolver: &dyn ImageResolver, + ) { + let mut regions = Regions::new(width, height, buffer); + let mut fine = Fine::::new(simd); + + regions.update_regions(|region| { + let x = region.x; + let y = region.y; + + let wtile = self.wide.get(x, y); + fine.set_coords(x, y); + + // Clear to background and process all commands in order. + fine.clear(wtile.bg); + for cmd in &wtile.cmds { + fine.run_cmd( + cmd, + &self.strip_storage.alphas, + encoded_paints, + image_resolver, + &self.wide.attrs, + ); + } + + fine.pack(region); + }); + } + + /// Returns true if the scene contains any filter effects. + fn has_filters(&self) -> bool { + self.render_graph.has_filters() + } + + /// Composites at an offset using f32 precision (high quality). + #[cfg(feature = "f32_pipeline")] + fn composite_at_offset_f32( + &self, + buffer: &mut [u8], + width: u16, + height: u16, + dst_x: u16, + dst_y: u16, + dst_buffer_width: u16, + dst_buffer_height: u16, + encoded_paints: &[EncodedPaint], + image_resolver: &dyn ImageResolver, + ) { + use crate::fine::F32Kernel; + use vello_common::fearless_simd::dispatch; + dispatch!(self.level, simd => self.composite_at_offset_with::<_, F32Kernel>( + simd, buffer, width, height, dst_x, dst_y, dst_buffer_width, dst_buffer_height, encoded_paints, image_resolver + )); + } + + /// Composites at an offset using u8 precision (fast). + #[cfg(feature = "u8_pipeline")] + fn composite_at_offset_u8( + &self, + buffer: &mut [u8], + width: u16, + height: u16, + dst_x: u16, + dst_y: u16, + dst_buffer_width: u16, + dst_buffer_height: u16, + encoded_paints: &[EncodedPaint], + image_resolver: &dyn ImageResolver, + ) { + use crate::fine::U8Kernel; + use vello_common::fearless_simd::dispatch; + dispatch!(self.level, simd => self.composite_at_offset_with::<_, U8Kernel>( + simd, buffer, width, height, dst_x, dst_y, dst_buffer_width, dst_buffer_height, encoded_paints, image_resolver + )); + } + + /// Core implementation for compositing at an offset. + /// + /// Composites tiles sequentially, writing directly to the destination buffer + /// at the specified offset. + fn composite_at_offset_with>( + &self, + simd: S, + buffer: &mut [u8], + width: u16, + height: u16, + dst_x: u16, + dst_y: u16, + dst_buffer_width: u16, + dst_buffer_height: u16, + encoded_paints: &[EncodedPaint], + image_resolver: &dyn ImageResolver, + ) { + let mut regions = Regions::new_at_offset( + width, + height, + dst_x, + dst_y, + dst_buffer_width, + dst_buffer_height, + buffer, + ); + let mut fine = Fine::::new(simd); + + regions.update_regions(|region| { + let x = region.x; + let y = region.y; + + let wtile = self.wide.get(x, y); + fine.set_coords(x, y); + + // Unpack existing pixel data from the region instead of clearing, + // so that rendering composites onto the existing pixmap contents. + fine.unpack(region); + for cmd in &wtile.cmds { + fine.run_cmd( + cmd, + &self.strip_storage.alphas, + encoded_paints, + image_resolver, + &self.wide.attrs, + ); + } + fine.pack(region); + }); + } +} + +impl Dispatcher for SingleThreadedDispatcher { + fn wide(&self) -> &Wide { + &self.wide + } + + fn fill_path( + &mut self, + path: &BezPath, + fill_rule: Fill, + transform: Affine, + paint: Paint, + blend_mode: BlendMode, + aliasing_threshold: Option, + mask: Option, + encoded_paints: &[EncodedPaint], + ) { + let wide = &mut self.wide; + + // Convert path to coverage strips. + self.strip_generator.generate_filled_path( + path, + fill_rule, + transform, + aliasing_threshold, + &mut self.strip_storage, + self.clip_context.get(), + ); + + // Generate coarse-level commands from strips (thread_idx 0 for single-threaded). + wide.generate( + &self.strip_storage.strips, + paint, + blend_mode, + 0, + mask, + encoded_paints, + ); + } + + fn stroke_path( + &mut self, + path: &BezPath, + stroke: &Stroke, + transform: Affine, + paint: Paint, + blend_mode: BlendMode, + aliasing_threshold: Option, + mask: Option, + encoded_paints: &[EncodedPaint], + ) { + let wide = &mut self.wide; + + // Convert stroked path to coverage strips. + self.strip_generator.generate_stroked_path( + path, + stroke, + transform, + aliasing_threshold, + &mut self.strip_storage, + self.clip_context.get(), + ); + + // Generate coarse-level commands from strips (thread_idx 0 for single-threaded). + wide.generate( + &self.strip_storage.strips, + paint, + blend_mode, + 0, + mask, + encoded_paints, + ); + } + + fn fill_rect_fast( + &mut self, + rect: &Rect, + paint: Paint, + blend_mode: BlendMode, + mask: Option, + encoded_paints: &[EncodedPaint], + ) { + let wide = &mut self.wide; + + // Generate strips directly for the rectangle (bypasses path processing). + self.strip_generator.generate_filled_rect_fast( + rect, + &mut self.strip_storage, + self.clip_context.get(), + ); + + // Generate coarse-level commands from strips (thread_idx 0 for single-threaded). + wide.generate( + &self.strip_storage.strips, + paint, + blend_mode, + 0, + mask, + encoded_paints, + ); + } + + fn push_layer( + &mut self, + clip_path: Option<&BezPath>, + fill_rule: Fill, + transform: Affine, + blend_mode: BlendMode, + opacity: f32, + aliasing_threshold: Option, + mask: Option, + filter: Option, + ) { + // Allocate a new unique layer ID. + self.layer_id_next += 1; + + // Generate clip coverage if a clip path is provided. + let clip = if let Some(c) = clip_path { + self.strip_generator.generate_filled_path( + c, + fill_rule, + transform, + aliasing_threshold, + &mut self.strip_storage, + self.clip_context.get(), + ); + + Some(self.strip_storage.strips.as_slice()) + } else { + None + }; + + // Push the layer onto the coarse tile stack and update render graph. + self.wide.push_layer( + self.layer_id_next, + clip, + blend_mode, + mask, + opacity, + filter, + transform, + &mut self.render_graph, + 0, + ); + } + + fn pop_layer(&mut self) { + // Pop the current layer and update render graph. + self.wide.pop_layer(&mut self.render_graph); + } + + fn reset(&mut self) { + // Clear all rendering state to prepare for a new scene. + self.wide.reset(); + self.clip_context.reset(); + self.strip_generator.reset(); + self.strip_storage.clear(); + self.render_graph.clear(); + self.layer_id_next = 0; + + // Recreate root node as node 0 (required for proper execution order). + let root_node = self.render_graph.add_node(RenderNodeKind::RootLayer { + layer_id: 0, + wtile_bbox: WideTilesBbox::new(0, 0, self.wide.width_tiles(), self.wide.height_tiles()), + }); + debug_assert_eq!(root_node, 0, "Root node must be node 0"); + + // Reset layer ID counter. + self.layer_id_next = 0; + } + + fn flush(&mut self, _encoded_paints: &[EncodedPaint]) { + // No-op for single-threaded dispatcher (no work queue to flush). + } + + fn rasterize( + &self, + buffer: &mut [u8], + render_mode: RenderMode, + width: u16, + height: u16, + encoded_paints: &[EncodedPaint], + image_resolver: &dyn ImageResolver, + ) { + // If only the u8 pipeline is enabled, then use it + #[cfg(all(feature = "u8_pipeline", not(feature = "f32_pipeline")))] + { + let _ = render_mode; + self.rasterize_u8(buffer, width, height, encoded_paints, image_resolver); + } + + // If only the f32 pipeline is enabled, then use it + #[cfg(all(feature = "f32_pipeline", not(feature = "u8_pipeline")))] + { + let _ = render_mode; + self.rasterize_f32(buffer, width, height, encoded_paints, image_resolver); + } + + // If both pipelines are enabled, select precision based on render mode parameter. + #[cfg(all(feature = "u8_pipeline", feature = "f32_pipeline"))] + match render_mode { + RenderMode::OptimizeSpeed => { + // Use u8 precision for faster rendering. + self.rasterize_u8(buffer, width, height, encoded_paints, image_resolver); + } + RenderMode::OptimizeQuality => { + // Use f32 precision for higher quality. + self.rasterize_f32(buffer, width, height, encoded_paints, image_resolver); + } + } + + #[cfg(all(not(feature = "u8_pipeline"), not(feature = "f32_pipeline")))] + { + // This case never gets hit because there is a compile_error in the root. + // But have this code disables some warnings and makes the compile error easier to read + let _ = ( + buffer, + render_mode, + width, + height, + encoded_paints, + image_resolver, + ); + } + } + + fn composite_at_offset( + &self, + buffer: &mut [u8], + width: u16, + height: u16, + dst_x: u16, + dst_y: u16, + dst_buffer_width: u16, + dst_buffer_height: u16, + render_mode: RenderMode, + encoded_paints: &[EncodedPaint], + image_resolver: &dyn ImageResolver, + ) { + #[cfg(all(feature = "u8_pipeline", not(feature = "f32_pipeline")))] + { + let _ = render_mode; + self.composite_at_offset_u8( + buffer, + width, + height, + dst_x, + dst_y, + dst_buffer_width, + dst_buffer_height, + encoded_paints, + image_resolver, + ); + } + + #[cfg(all(feature = "f32_pipeline", not(feature = "u8_pipeline")))] + { + let _ = render_mode; + self.composite_at_offset_f32( + buffer, + width, + height, + dst_x, + dst_y, + dst_buffer_width, + dst_buffer_height, + encoded_paints, + image_resolver, + ); + } + + #[cfg(all(feature = "u8_pipeline", feature = "f32_pipeline"))] + match render_mode { + RenderMode::OptimizeSpeed => { + self.composite_at_offset_u8( + buffer, + width, + height, + dst_x, + dst_y, + dst_buffer_width, + dst_buffer_height, + encoded_paints, + image_resolver, + ); + } + RenderMode::OptimizeQuality => { + self.composite_at_offset_f32( + buffer, + width, + height, + dst_x, + dst_y, + dst_buffer_width, + dst_buffer_height, + encoded_paints, + image_resolver, + ); + } + } + + #[cfg(all(not(feature = "u8_pipeline"), not(feature = "f32_pipeline")))] + { + let _ = ( + buffer, + width, + height, + dst_x, + dst_y, + dst_buffer_width, + dst_buffer_height, + render_mode, + encoded_paints, + image_resolver, + ); + } + } + + fn push_clip_path( + &mut self, + path: &BezPath, + fill_rule: Fill, + transform: Affine, + aliasing_threshold: Option, + ) { + self.clip_context.push_clip( + path, + &mut self.strip_generator, + fill_rule, + transform, + aliasing_threshold, + ); + } + + fn pop_clip_path(&mut self) { + self.clip_context.pop_clip(); + } +} + +/// Saves a filtered pixmap to disk for debugging purposes. +/// Only available in debug builds with `std` and `png` features enabled. +#[allow( + dead_code, + reason = "useful debug utility, can be enabled by uncommenting the call site" +)] +#[cfg(all(debug_assertions, feature = "std", feature = "png"))] +fn save_filtered_layer_debug(pixmap: &Pixmap, layer_id: u32) { + use std::path::PathBuf; + + let diffs_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../vello_sparse_tests/diffs"); + let _ = std::fs::create_dir_all(&diffs_path); + let filename = diffs_path.join(alloc::format!("filtered_layer_{}.png", layer_id)); + + if let Ok(png_data) = pixmap.clone().into_png() { + let _ = std::fs::write(&filename, &png_data); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::kurbo::Rect; + use vello_common::color::palette::css::BLUE; + use vello_common::kurbo::Shape; + use vello_common::paint::PremulColor; + + /// Verifies that `reset()` properly clears all internal buffers and state. + /// + /// This is important to ensure that a dispatcher can be reused for multiple + /// rendering passes without accumulating stale data from previous frames. + #[test] + fn buffers_cleared_on_reset() { + let mut dispatcher = SingleThreadedDispatcher::new(100, 100, Level::new()); + + // Render a simple shape to populate internal buffers. + dispatcher.fill_path( + &Rect::new(0.0, 0.0, 50.0, 50.0).to_path(0.1), + Fill::NonZero, + Affine::IDENTITY, + Paint::Solid(PremulColor::from_alpha_color(BLUE)), + BlendMode::default(), + None, + None, + &[], + ); + + // Ensure there is data to clear. + assert!(!dispatcher.strip_storage.alphas.is_empty()); + assert!(!dispatcher.wide.get(0, 0).cmds.is_empty()); + + dispatcher.reset(); + + // Verify all buffers are cleared. + assert!(dispatcher.strip_storage.alphas.is_empty()); + assert!(dispatcher.wide.get(0, 0).cmds.is_empty()); + assert_eq!(dispatcher.layer_id_next, 0); + } +} diff --git a/vendor/vello_cpu/src/filter/drop_shadow.rs b/vendor/vello_cpu/src/filter/drop_shadow.rs new file mode 100644 index 0000000000..83f0cf3be6 --- /dev/null +++ b/vendor/vello_cpu/src/filter/drop_shadow.rs @@ -0,0 +1,312 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Drop shadow filter implementation. +//! +//! This implements the feDropShadow primitive from SVG Filter Effects 2. +//! The drop shadow effect is a shorthand for a commonly used sequence of filter operations: +//! 1. Extract alpha channel +//! 2. Offset the alpha +//! 3. Blur the offset alpha +//! 4. Composite shadow color with blurred alpha +//! 5. Composite shadow with original graphic +//! +//! @see + +use super::FilterEffect; +use super::gaussian_blur::apply_blur; +use super::shift::offset_pixels; +use crate::layer_manager::LayerManager; +use vello_common::color::{AlphaColor, Srgb}; +use vello_common::filter::drop_shadow::DropShadow; +use vello_common::filter_effects::EdgeMode; +use vello_common::peniko::color::PremulRgba8; +#[cfg(not(feature = "std"))] +use vello_common::peniko::kurbo::common::FloatFuncs as _; +use vello_common::pixmap::Pixmap; + +impl FilterEffect for DropShadow { + fn execute_lowp(&self, pixmap: &mut Pixmap, layer_manager: &mut LayerManager) { + apply_drop_shadow( + pixmap, + self.dx, + self.dy, + self.std_deviation, + self.n_decimations, + &self.kernel[..usize::from(self.kernel_size)], + self.color, + self.edge_mode, + layer_manager, + ); + } + + fn execute_highp(&self, pixmap: &mut Pixmap, layer_manager: &mut LayerManager) { + // TODO: Currently only lowp is implemented and used for highp as well. + // This needs to be updated to use proper high-precision arithmetic. + Self::execute_lowp(self, pixmap, layer_manager); + } +} + +/// Apply drop shadow effect. +/// +/// This is the main entry point that splits the drop shadow operation into well-defined steps: +/// 1. Offset the shadow pixels +/// 2. Blur the already-offset shadow +/// 3. Apply shadow color and composite with original +fn apply_drop_shadow( + pixmap: &mut Pixmap, + dx: f32, + dy: f32, + std_deviation: f32, + n_decimations: usize, + kernel: &[f32], + color: AlphaColor, + edge_mode: EdgeMode, + layer_manager: &mut LayerManager, +) { + // Clone pixmap to create shadow buffer + let mut shadow_pixmap = pixmap.clone(); + + // Step 1: Offset the shadow pixels + offset_pixels(&mut shadow_pixmap, dx, dy); + + // Step 2: Blur the already-offset shadow + if std_deviation > 0.0 { + let scratch = + layer_manager.get_scratch_buffer(shadow_pixmap.width(), shadow_pixmap.height()); + apply_blur( + &mut shadow_pixmap, + scratch, + n_decimations, + kernel, + edge_mode, + ); + } + + // Step 3: Apply shadow color and composite with original + compose_shadow_direct(&shadow_pixmap, pixmap, color); +} + +/// Apply shadow color and composite with original. +/// +/// The shadow has already been offset and blurred, so this simply applies +/// the shadow color to the alpha channel and composites using source-over. +fn compose_shadow_direct(shadow: &Pixmap, dst: &mut Pixmap, color: AlphaColor) { + let width = dst.width(); + let height = dst.height(); + + // Precompute shadow color components + let shadow_r = (color.components[0] * 255.0).round() as u8; + let shadow_g = (color.components[1] * 255.0).round() as u8; + let shadow_b = (color.components[2] * 255.0).round() as u8; + + for y in 0..height { + for x in 0..width { + // Sample alpha directly (shadow is already offset) + let alpha = shadow.sample(x, y).a; + + // Apply shadow color to alpha + let shadow_alpha = (u8_to_norm(alpha) * color.components[3]).min(1.0); + let final_alpha = norm_to_u8(shadow_alpha); + + // Premultiply RGB by alpha as required by PremulRgba8 + let alpha_u16 = u16::from(final_alpha); + let premultiply = |channel: u8| ((u16::from(channel) * alpha_u16) / 255) as u8; + + let colored_shadow = PremulRgba8 { + r: premultiply(shadow_r), + g: premultiply(shadow_g), + b: premultiply(shadow_b), + a: final_alpha, + }; + + // Read original and composite: original over shadow + let original_pixel = dst.sample(x, y); + let result = compose_src_over(original_pixel, colored_shadow); + + dst.set_pixel(x, y, result); + } + } +} + +/// Composite two pixels using Porter-Duff "source over" operator. +/// +/// Composes the source pixel over the destination pixel using premultiplied +/// alpha blending. Returns the composited result. +/// +/// Formula for premultiplied colors: `result = src + dst * (1 - src_alpha)` +fn compose_src_over(src: PremulRgba8, dst: PremulRgba8) -> PremulRgba8 { + let src_a = u8_to_norm(src.a); + + PremulRgba8 { + r: src_over_channel(src.r, dst.r, src_a), + g: src_over_channel(src.g, dst.g, src_a), + b: src_over_channel(src.b, dst.b, src_a), + a: src_over_channel(src.a, dst.a, src_a), + } +} + +/// Blend a single channel using Porter-Duff "source over" operator. +/// +/// For premultiplied colors, the formula is: `result = src + dst * (1 - src_alpha)` +#[inline] +fn src_over_channel(src: u8, dst: u8, src_alpha: f32) -> u8 { + let result = u8_to_norm(src) + u8_to_norm(dst) * (1.0 - src_alpha); + norm_to_u8(result) +} + +/// Convert a u8 color component (0-255) to normalized f32 (0.0-1.0). +#[inline] +fn u8_to_norm(value: u8) -> f32 { + value as f32 / 255.0 +} + +/// Convert a normalized f32 (0.0-1.0) to u8 color component (0-255). +#[inline] +fn norm_to_u8(value: f32) -> u8 { + (value * 255.0).round() as u8 +} + +#[cfg(test)] +mod tests { + use super::*; + use vello_common::color::Srgb; + + /// Test `u8_to_norm` conversion. + #[test] + fn test_u8_to_norm() { + assert_eq!(u8_to_norm(0), 0.0); + assert!((u8_to_norm(255) - 1.0).abs() < 1e-6); + } + + /// Test `norm_to_u8` conversion. + #[test] + fn test_norm_to_u8() { + assert_eq!(norm_to_u8(0.0), 0); + assert_eq!(norm_to_u8(1.0), 255); + assert_eq!(norm_to_u8(0.5), 128); // 0.5 * 255 = 127.5 → 128 + } + + /// Test round-trip conversion u8 → norm → u8. + #[test] + fn test_conversion_roundtrip() { + for value in [0, 1, 50, 127, 128, 200, 254, 255] { + let normalized = u8_to_norm(value); + let back = norm_to_u8(normalized); + assert_eq!(back, value); + } + } + + /// Test Porter-Duff source-over with fully opaque source. + #[test] + fn test_compose_src_over_opaque_source() { + let src = PremulRgba8 { + r: 255, + g: 0, + b: 0, + a: 255, + }; // Opaque red + let dst = PremulRgba8 { + r: 0, + g: 255, + b: 0, + a: 255, + }; // Opaque green + + let result = compose_src_over(src, dst); + // Opaque source should completely cover destination + assert_eq!(result.r, 255); + assert_eq!(result.g, 0); + assert_eq!(result.b, 0); + assert_eq!(result.a, 255); + } + + /// Test Porter-Duff source-over with transparent source. + #[test] + fn test_compose_src_over_transparent_source() { + let src = PremulRgba8 { + r: 0, + g: 0, + b: 0, + a: 0, + }; + let dst = PremulRgba8 { + r: 0, + g: 255, + b: 0, + a: 255, + }; + + let result = compose_src_over(src, dst); + // Transparent source should leave destination unchanged + assert_eq!(result.r, 0); + assert_eq!(result.g, 255); + assert_eq!(result.b, 0); + assert_eq!(result.a, 255); + } + + /// Test Porter-Duff source-over with semi-transparent source. + #[test] + fn test_compose_src_over_semi_transparent() { + let src = PremulRgba8 { + r: 128, + g: 0, + b: 0, + a: 128, + }; // 50% red (premul) + let dst = PremulRgba8 { + r: 0, + g: 128, + b: 0, + a: 128, + }; // 50% green (premul) + + let result = compose_src_over(src, dst); + // Result should blend src + dst*(1-src_alpha) + // r: 128 + 0*(1-0.5) = 128 + // g: 0 + 128*0.5 = 64 + // a: 128 + 128*0.5 = 192 + assert_eq!( + result, + PremulRgba8 { + r: 128, + g: 64, + b: 0, + a: 192, + } + ); + } + + /// Test `compose_shadow_direct` applies color correctly. + #[test] + fn test_compose_shadow_color() { + let mut shadow_pixmap = Pixmap::new(2, 2); + let mut dst_pixmap = Pixmap::new(2, 2); + + // Shadow has alpha=255 at (0,0) + shadow_pixmap.set_pixel( + 0, + 0, + PremulRgba8 { + r: 0, + g: 0, + b: 0, + a: 255, + }, + ); + + let shadow_color = AlphaColor { + components: [1.0, 0.0, 0.0, 1.0], // Red + cs: std::marker::PhantomData::, + }; + + compose_shadow_direct(&shadow_pixmap, &mut dst_pixmap, shadow_color); + + // Shadow at (0,0) should be red + let result = dst_pixmap.sample(0, 0); + assert_eq!(result.r, 255); + assert_eq!(result.g, 0); + assert_eq!(result.b, 0); + assert_eq!(result.a, 255); + } +} diff --git a/vendor/vello_cpu/src/filter/flood.rs b/vendor/vello_cpu/src/filter/flood.rs new file mode 100644 index 0000000000..1d300debac --- /dev/null +++ b/vendor/vello_cpu/src/filter/flood.rs @@ -0,0 +1,89 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Flood filter implementation. + +use super::FilterEffect; +use crate::layer_manager::LayerManager; +use vello_common::filter::flood::Flood; +use vello_common::pixmap::Pixmap; + +impl FilterEffect for Flood { + fn execute_lowp(&self, pixmap: &mut Pixmap, _layer_manager: &mut LayerManager) { + pixmap.data_mut().fill(self.color.premultiply().to_rgba8()); + } + + fn execute_highp(&self, pixmap: &mut Pixmap, _layer_manager: &mut LayerManager) { + pixmap.data_mut().fill(self.color.premultiply().to_rgba8()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::color::AlphaColor; + use crate::layer_manager::LayerManager; + use vello_common::color::{PremulRgba8, Srgb}; + + /// Test flood with semi-transparent color - verifies correct premultiplication. + #[test] + fn test_flood_semi_transparent_lowp() { + let mut pixmap = Pixmap::new(2, 2); + let mut layer_manager = LayerManager::new(); + + // Semi-transparent white (50% alpha) + let color = AlphaColor { + components: [1.0, 1.0, 1.0, 0.5], + cs: std::marker::PhantomData::, + }; + let flood = Flood::new(color); + flood.execute_lowp(&mut pixmap, &mut layer_manager); + + // RGB should be premultiplied by alpha: 255 * 0.5 = 127-128 + for y in 0..2 { + for x in 0..2 { + let pixel = pixmap.sample(x, y); + assert_eq!( + pixel, + PremulRgba8 { + r: 128, + g: 128, + b: 128, + a: 128 + } + ); + } + } + } + + /// Test flood highp with semi-transparent color - verifies correct premultiplication. + #[test] + fn test_flood_semi_transparent_highp() { + let mut pixmap = Pixmap::new(2, 2); + let mut layer_manager = LayerManager::new(); + + // Semi-transparent white (50% alpha) + let color = AlphaColor { + components: [1.0, 1.0, 1.0, 0.5], + cs: std::marker::PhantomData::, + }; + let flood = Flood::new(color); + flood.execute_highp(&mut pixmap, &mut layer_manager); + + // RGB should be premultiplied by alpha: 255 * 0.5 = 127-128 + for y in 0..2 { + for x in 0..2 { + let pixel = pixmap.sample(x, y); + assert_eq!( + pixel, + PremulRgba8 { + r: 128, + g: 128, + b: 128, + a: 128 + } + ); + } + } + } +} diff --git a/vendor/vello_cpu/src/filter/gaussian_blur.rs b/vendor/vello_cpu/src/filter/gaussian_blur.rs new file mode 100644 index 0000000000..2d9bd1b2ed --- /dev/null +++ b/vendor/vello_cpu/src/filter/gaussian_blur.rs @@ -0,0 +1,1147 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Gaussian blur filter implementation using multi-scale separable convolution. +//! +//! This implementation uses a multi-scale approach for efficient blurring: +//! - **Small blurs** (σ ≤ 2): Direct separable convolution at full resolution +//! - **Large blurs** (σ > 2): Iterative downsample → blur → upsample pyramid +//! +//! The algorithm automatically determines the optimal number of decimation levels +//! using variance analysis. Each 2× decimation applies a \[1,3,3,1\]/8 binomial filter +//! (adding variance = 3.0), then downsamples, reducing the remaining blur work needed. +//! This exploits the variance additivity property: `σ²_total = σ²_downsample + σ²_blur`. +//! +//! **Variance Addition Reference**: The convolution of two Gaussians with variances σ₁² +//! and σ₂² produces a Gaussian with variance σ₁² + σ₂². This fundamental property comes +//! from probability theory and applies to Gaussian convolution in image processing. See: +//! - Torralba & Freeman, "Foundations of Computer Vision" (MIT Press), Section 2.2: +//! + +use super::FilterEffect; +use crate::layer_manager::LayerManager; +use vello_common::filter::gaussian_blur::{DecimationSizer, GaussianBlur}; +use vello_common::filter_effects::EdgeMode; +use vello_common::peniko::color::PremulRgba8; +#[cfg(not(feature = "std"))] +use vello_common::peniko::kurbo::common::FloatFuncs as _; +use vello_common::pixmap::Pixmap; + +impl FilterEffect for GaussianBlur { + fn execute_lowp(&self, pixmap: &mut Pixmap, layer_manager: &mut LayerManager) { + // No blur if std_deviation is zero or negative + if self.std_deviation <= 0.0 { + return; + } + + let scratch = layer_manager.get_scratch_buffer(pixmap.width(), pixmap.height()); + apply_blur( + pixmap, + scratch, + self.n_decimations, + &self.kernel[..usize::from(self.kernel_size)], + self.edge_mode, + ); + } + + fn execute_highp(&self, pixmap: &mut Pixmap, layer_manager: &mut LayerManager) { + // TODO: Currently only lowp is implemented and used for highp as well. + // This needs to be updated to use proper high-precision arithmetic. + Self::execute_lowp(self, pixmap, layer_manager); + } +} + +/// Apply Gaussian blur using multi-scale decimation and upsampling. +/// +/// Uses a precomputed decimation plan and kernel for optimal performance. +/// Operates in-place using a single pixmap buffer with logical dimension tracking +/// to minimize memory allocations. For `n_decimations=0`, applies direct convolution. +/// +/// The `scratch` buffer is used for separable convolution and must be at least as +/// large as the source pixmap. +pub(crate) fn apply_blur( + pixmap: &mut Pixmap, + scratch: &mut Pixmap, + n_decimations: usize, + kernel: &[f32], + edge_mode: EdgeMode, +) { + let radius = (kernel.len() / 2) as u8; + let width = pixmap.width(); + let height = pixmap.height(); + + // Small blur: apply direct convolution at full resolution + if n_decimations == 0 { + convolve(pixmap, scratch, width, height, kernel, radius, edge_mode); + return; + } + + // Track logical dimensions through decimation (physical buffer stays the same size) + let mut sizer = DecimationSizer::new(width, height); + + // Downsample n times (each step reduces resolution by 2×) + for _ in 0..n_decimations { + let (w, h) = sizer.current(); + downscale(pixmap, w, h, edge_mode); + sizer.downscale(); + } + + // Apply the reduced blur at the coarsest resolution + let (w, h) = sizer.current(); + convolve(pixmap, scratch, w, h, kernel, radius, edge_mode); + + // Upsample back to original resolution (each step doubles resolution by 2×) + for _ in 0..n_decimations { + let (w, h) = sizer.current(); + upscale(pixmap, w, h, edge_mode); + sizer.upscale(); + } + + debug_assert_eq!( + sizer.current(), + (width, height), + "Final dimensions should match original" + ); +} + +/// Apply separable Gaussian convolution with logical dimensions. +/// +/// Performs horizontal blur followed by vertical blur. Works with a logical view +/// of the pixmap, using only the top-left region defined by width × height. +/// The `temp` buffer is provided by the caller to avoid allocations. +pub(crate) fn convolve( + src: &mut Pixmap, + scratch: &mut Pixmap, + width: u16, + height: u16, + kernel: &[f32], + radius: u8, + edge_mode: EdgeMode, +) { + convolve_x(src, scratch, width, height, kernel, radius, edge_mode); + convolve_y(scratch, src, width, height, kernel, radius, edge_mode); +} + +/// Apply horizontal blur pass (1D convolution along x-axis). +/// +/// For each output pixel, computes a weighted sum of horizontally neighboring pixels +/// using the Gaussian kernel. Handles edge cases according to the specified edge mode. +/// Writes results to a destination buffer to avoid overwriting source data. +pub(crate) fn convolve_x( + src: &Pixmap, + dst: &mut Pixmap, + src_width: u16, + src_height: u16, + kernel: &[f32], + radius: u8, + edge_mode: EdgeMode, +) { + for y in 0..src_height { + for x in 0..src_width { + let mut rgba = [0.0_f32; 4]; + + // Sum contributions from all kernel positions: output = Σ(weight[j] × pixel[x+j-radius]) + for (j, &k) in kernel.iter().enumerate() { + #[expect( + clippy::cast_possible_wrap, + reason = "This cast never wraps because `kernel.len()` is never greater than `u8::MAX` due to the restriction on `MAX_KERNEL_SIZE`" + )] + let j = j as i32; + let src_x = x as i32 + j - radius as i32; + let p = sample_x(src, src_x, y, src_width, edge_mode); + + rgba[0] += p.r as f32 * k; + rgba[1] += p.g as f32 * k; + rgba[2] += p.b as f32 * k; + rgba[3] += p.a as f32 * k; + } + + // Convert back to u8 with rounding + dst.set_pixel( + x, + y, + PremulRgba8 { + r: rgba[0].round() as u8, + g: rgba[1].round() as u8, + b: rgba[2].round() as u8, + a: rgba[3].round() as u8, + }, + ); + } + } +} + +/// Apply vertical blur pass (1D convolution along y-axis). +/// +/// For each output pixel, computes a weighted sum of vertically neighboring pixels +/// using the Gaussian kernel. Handles edge cases according to the specified edge mode. +/// Writes results to a destination buffer to avoid overwriting source data. +pub(crate) fn convolve_y( + src: &Pixmap, + dst: &mut Pixmap, + src_width: u16, + src_height: u16, + kernel: &[f32], + radius: u8, + edge_mode: EdgeMode, +) { + for y in 0..src_height { + for x in 0..src_width { + let mut rgba = [0.0_f32; 4]; + + // Sum contributions from all kernel positions: output = Σ(weight[j] × pixel[y+j-radius]) + for (j, &k) in kernel.iter().enumerate() { + #[expect( + clippy::cast_possible_wrap, + reason = "This cast never wraps because `kernel.len()` is never greater than `u8::MAX` due to the restriction on `MAX_KERNEL_SIZE`" + )] + let j = j as i32; + let src_y = y as i32 + j - radius as i32; + let p = sample_y(src, x, src_y, src_height, edge_mode); + + rgba[0] += p.r as f32 * k; + rgba[1] += p.g as f32 * k; + rgba[2] += p.b as f32 * k; + rgba[3] += p.a as f32 * k; + } + + // Convert back to u8 with rounding + dst.set_pixel( + x, + y, + PremulRgba8 { + r: rgba[0].round() as u8, + g: rgba[1].round() as u8, + b: rgba[2].round() as u8, + a: rgba[3].round() as u8, + }, + ); + } + } +} + +/// Downsample image by 2x using separable \[1,3,3,1\]/8 binomial filter. +/// +/// Performs horizontal and vertical decimation in sequence. Returns the new +/// logical dimensions (ceil(width/2), ceil(height/2)). +pub(crate) fn downscale( + src: &mut Pixmap, + src_width: u16, + src_height: u16, + edge_mode: EdgeMode, +) -> (u16, u16) { + let dst_width = src_width.div_ceil(2); + let dst_height = src_height.div_ceil(2); + downscale_x(src, src_width, src_height, dst_width, edge_mode); + // We can pass `dst_width` instead of `src_width` here, since we already decimated + // horizontally. + downscale_y(src, dst_width, src_height, dst_height, edge_mode); + (dst_width, dst_height) +} + +/// Horizontal decimation pass using \[1,3,3,1\]/8 filter. +/// +/// Reduces width by 2x while applying a binomial blur kernel. The \[1,3,3,1\] weights +/// approximate a Gaussian and contribute variance=0.75 before the 2x downsampling. +fn downscale_x( + src: &mut Pixmap, + src_width: u16, + src_height: u16, + dst_width: u16, + edge_mode: EdgeMode, +) { + for y in 0..src_height { + // Sliding window: maintains 2 previous pixels (x0, x1) to form 4-tap filter + // with current pixels (x2, x3). Start with sample at -1 for implicit left padding. + let mut p0 = sample_x(src, -1, y, src_width, edge_mode); + let mut p1 = sample_x(src, 0, y, src_width, edge_mode); + + for x in 0..dst_width { + // Sample 4 horizontally adjacent pixels for [1,3,3,1] kernel + // Pattern: [x*2-1, x*2, x*2+1, x*2+2] + let src_x = (x * 2) as i32; + let p2 = sample_x(src, src_x + 1, y, src_width, edge_mode); + let p3 = sample_x(src, src_x + 2, y, src_width, edge_mode); + + // Apply [1,3,3,1]/8 weights → output = (p0 + 3×p1 + 3×p2 + p3) / 8 + src.set_pixel(x, y, decimate_weighted(p0, p1, p2, p3)); + + // Advance window: previous p2,p3 become next p0,p1 + p0 = p2; + p1 = p3; + } + } +} + +/// Vertical decimation pass using \[1,3,3,1\]/8 filter. +/// +/// Reduces logical height by 2x while applying a binomial blur kernel. +/// Operates in-place by writing to the beginning of the same buffer. +fn downscale_y( + src: &mut Pixmap, + src_width: u16, + src_height: u16, + dst_height: u16, + edge_mode: EdgeMode, +) { + for x in 0..src_width { + // Sliding window: maintains 2 previous pixels (y0, y1) to form 4-tap filter + // with current pixels (y2, y3). Start with sample at -1 for implicit top padding. + let mut p0 = sample_y(src, x, -1, src_height, edge_mode); + let mut p1 = sample_y(src, x, 0, src_height, edge_mode); + + for y in 0..dst_height { + // Sample 4 vertically adjacent pixels for [1,3,3,1] kernel + // Pattern: [y*2-1, y*2, y*2+1, y*2+2] + let src_y = (y * 2) as i32; + let p2 = sample_y(src, x, src_y + 1, src_height, edge_mode); + let p3 = sample_y(src, x, src_y + 2, src_height, edge_mode); + + // Apply [1,3,3,1]/8 weights → output = (p0 + 3×p1 + 3×p2 + p3) / 8 + src.set_pixel(x, y, decimate_weighted(p0, p1, p2, p3)); + + // Advance window: previous p2,p3 become next p0,p1 + p0 = p2; + p1 = p3; + } + } +} + +/// Upsample a pixmap by 2x using linear interpolation with [0.75, 0.25] weights. +/// +/// Uses separable passes: horizontal doubling followed by vertical doubling. +/// +/// ## Phase Alignment Theory +/// +/// The downsampling \[1,3,3,1\]/8 filter creates a half-pixel offset. Its center of mass +/// is at position `2k + 0.5`, not `2k`. This means each downsampled pixel at discrete +/// position `k` represents a sample at continuous position `2k + 0.5`. +/// +/// When upsampling, we reconstruct pixels at positions `2k` and `2k+1` from downsampled +/// pixels whose centers are at `..., 2k-1.5, 2k+0.5, 2k+2.5, ...` +/// +/// **Interpolation weights** are derived from linear interpolation based on distances: +/// - Position `2k`: distance 0.5 from center at `2k+0.5`, distance 1.5 from center at `2k-1.5` +/// → weights: 0.75×pixel\[k\] + 0.25×pixel\[k-1\] +/// - Position `2k+1`: distance 0.5 from center at `2k+0.5`, distance 1.5 from center at `2k+2.5` +/// → weights: 0.75×pixel\[k\] + 0.25×pixel\[k+1\] +pub(crate) fn upscale( + src: &mut Pixmap, + src_width: u16, + src_height: u16, + edge_mode: EdgeMode, +) -> (u16, u16) { + let dst_width = src_width * 2; + let dst_height = src_height * 2; + upscale_x(src, src_width, src_height, edge_mode); + upscale_y(src, dst_width, src_height, edge_mode); + (dst_width, dst_height) +} + +/// Horizontal upsampling pass using [0.75, 0.25] interpolation with logical dimensions. +/// +/// Doubles the logical width using phase-aligned interpolation. Each input pixel +/// generates two output pixels with different weights based on their distance from +/// the downsampled pixel's center position. +/// Operates in-place by processing backwards to avoid overwriting source data. +fn upscale_x(src: &mut Pixmap, src_width: u16, src_height: u16, edge_mode: EdgeMode) { + // Process backwards (right to left) to avoid overwriting source data + for y in 0..src_height { + // Maintain sliding window of three pixels: prev, current, next + // This allows us to compute both output pixels that depend on current pixel x + let mut p0 = sample_x(src, src_width as i32, y, src_width, edge_mode); + let mut p1 = sample_x(src, src_width as i32 - 1, y, src_width, edge_mode); + + for x in (0..src_width).rev() { + let src_x = x as i32; + let p2 = sample_x(src, src_x - 1, y, src_width, edge_mode); + + // Generate two output pixels per input with phase-aligned interpolation: + // output[2x] = 0.25×p2 + 0.75×p1 (position 2x is 0.5 from center at 2x+0.5) + // output[2x+1] = 0.75×p1 + 0.25×p0 (position 2x+1 is 0.5 from center at 2x+0.5) + let dst_x = x * 2; + src.set_pixel(dst_x, y, interpolate_25_75(p2, p1)); + src.set_pixel(dst_x + 1, y, interpolate_75_25(p1, p0)); + + // Advance sliding window for next iteration + p0 = p1; + p1 = p2; + } + } +} + +/// Vertical upsampling pass using [0.75, 0.25] interpolation with logical dimensions. +/// +/// Doubles the logical height using phase-aligned interpolation. Each input pixel +/// generates two output pixels with different weights based on their distance from +/// the downsampled pixel's center position. +/// Operates in-place by processing backwards to avoid overwriting source data. +fn upscale_y(src: &mut Pixmap, src_width: u16, src_height: u16, edge_mode: EdgeMode) { + // Process backwards (bottom to top) to avoid overwriting source data + for x in 0..src_width { + // Maintain sliding window of three pixels: prev, current, next + // This allows us to compute both output pixels that depend on current pixel y + let mut p0 = sample_y(src, x, src_height as i32, src_height, edge_mode); + let mut p1 = sample_y(src, x, src_height as i32 - 1, src_height, edge_mode); + + for y in (0..src_height).rev() { + let src_y = y as i32; + let p2 = sample_y(src, x, src_y - 1, src_height, edge_mode); + + // Generate two output rows per input with phase-aligned interpolation: + // output[2y] = 0.25×p2 + 0.75×p1 (position 2y is 0.5 from center at 2y+0.5) + // output[2y+1] = 0.75×p1 + 0.25×p0 (position 2y+1 is 0.5 from center at 2y+0.5) + let dst_y = y * 2; + src.set_pixel(x, dst_y, interpolate_25_75(p2, p1)); + src.set_pixel(x, dst_y + 1, interpolate_75_25(p1, p0)); + + // Advance sliding window for next iteration + p0 = p1; + p1 = p2; + } + } +} + +/// Transparent black pixel constant. +const TRANSPARENT_BLACK: PremulRgba8 = PremulRgba8 { + r: 0, + g: 0, + b: 0, + a: 0, +}; + +/// Sample a pixel with edge mode handling for horizontal sampling. +#[inline(always)] +fn sample_x(src: &Pixmap, x: i32, y: u16, width: u16, edge_mode: EdgeMode) -> PremulRgba8 { + sample(x, width, edge_mode, |src_x| src.sample(src_x, y)) +} + +/// Sample a pixel with edge mode handling for vertical sampling. +#[inline(always)] +fn sample_y(src: &Pixmap, x: u16, y: i32, height: u16, edge_mode: EdgeMode) -> PremulRgba8 { + sample(y, height, edge_mode, |src_y| src.sample(x, src_y)) +} + +/// Sample a pixel with edge mode handling (generic implementation). +/// +/// Handles both horizontal and vertical sampling based on the provided closure. +/// The `sample_fn` closure receives the clamped/extended coordinate and returns the pixel. +/// For `EdgeMode::None`, returns transparent black if the coordinate is out of bounds. +#[inline(always)] +fn sample(coord: i32, size: u16, edge_mode: EdgeMode, sample_fn: F) -> PremulRgba8 +where + F: FnOnce(u16) -> PremulRgba8, +{ + // For EdgeMode::None, return transparent black if out of bounds + if edge_mode == EdgeMode::None && (coord < 0 || coord >= size as i32) { + return TRANSPARENT_BLACK; + } + let extended_coord = extend(coord, size, edge_mode); + sample_fn(extended_coord) +} + +/// Extend a coordinate beyond image boundaries according to the edge mode. +/// +/// Transforms out-of-bounds coordinates for sampling: clamped, wrapped, or mirrored +/// depending on the mode. For `EdgeMode::None`, the coordinate is guaranteed to be +/// in-bounds (already checked by caller, which returns transparent black for out-of-bounds). +#[inline(always)] +fn extend(coord: i32, size: u16, edge_mode: EdgeMode) -> u16 { + match edge_mode { + EdgeMode::Duplicate => { + // Clamp to image bounds: pixels outside use nearest edge pixel + coord.clamp(0, size as i32 - 1) as u16 + } + EdgeMode::None => { + // Coordinate is already validated as in-bounds by caller + coord as u16 + } + EdgeMode::Wrap => { + // Wrap around using modulo: image tiles infinitely + let mut c = coord % size as i32; + if c < 0 { + c += size as i32; + } + c as u16 + } + EdgeMode::Mirror => { + // Mirror at boundaries: image reflects across edges + let period = size as i32 * 2; + let mut c = coord % period; + if c < 0 { + c += period; + } + if c >= size as i32 { + c = period - c - 1; + } + c as u16 + } + } +} + +/// Blend 4 RGBA pixels using \[1,3,3,1\]/8 binomial weights. +/// +/// Computes `(p0 + 3×p1 + 3×p2 + p3) / 8` using efficient integer arithmetic (right shift). +/// This binomial pattern approximates a Gaussian and contributes variance=0.75 to the blur. +/// Adds 4 before the shift to implement round-to-nearest instead of floor division. +#[inline(always)] +fn decimate_weighted( + p0: PremulRgba8, + p1: PremulRgba8, + p2: PremulRgba8, + p3: PremulRgba8, +) -> PremulRgba8 { + let r = ((p0.r as u32 + p1.r as u32 * 3 + p2.r as u32 * 3 + p3.r as u32 + 4) >> 3) as u8; + let g = ((p0.g as u32 + p1.g as u32 * 3 + p2.g as u32 * 3 + p3.g as u32 + 4) >> 3) as u8; + let b = ((p0.b as u32 + p1.b as u32 * 3 + p2.b as u32 * 3 + p3.b as u32 + 4) >> 3) as u8; + let a = ((p0.a as u32 + p1.a as u32 * 3 + p2.a as u32 * 3 + p3.a as u32 + 4) >> 3) as u8; + PremulRgba8 { r, g, b, a } +} + +/// Blend 2 RGBA pixels using [0.25, 0.75] weights (right-weighted interpolation). +/// +/// Used for upsampling to generate the second of two output pixels. +/// Favors the right/next pixel (p1) with 75% weight. +/// Adds 2 before the shift to implement round-to-nearest instead of floor division. +#[inline(always)] +fn interpolate_25_75(p0: PremulRgba8, p1: PremulRgba8) -> PremulRgba8 { + let r = ((p0.r as u32 + p1.r as u32 * 3 + 2) >> 2) as u8; + let g = ((p0.g as u32 + p1.g as u32 * 3 + 2) >> 2) as u8; + let b = ((p0.b as u32 + p1.b as u32 * 3 + 2) >> 2) as u8; + let a = ((p0.a as u32 + p1.a as u32 * 3 + 2) >> 2) as u8; + PremulRgba8 { r, g, b, a } +} + +/// Blend 2 RGBA pixels using [0.75, 0.25] weights. +/// +/// Computes `0.75×p0 + 0.25×p1` using efficient integer arithmetic. +/// Used during upsampling for positions closer to the first pixel. +/// Adds 2 before the shift to implement round-to-nearest instead of floor division. +#[inline(always)] +fn interpolate_75_25(p0: PremulRgba8, p1: PremulRgba8) -> PremulRgba8 { + let r = ((p0.r as u32 * 3 + p1.r as u32 + 2) >> 2) as u8; + let g = ((p0.g as u32 * 3 + p1.g as u32 + 2) >> 2) as u8; + let b = ((p0.b as u32 * 3 + p1.b as u32 + 2) >> 2) as u8; + let a = ((p0.a as u32 * 3 + p1.a as u32 + 2) >> 2) as u8; + PremulRgba8 { r, g, b, a } +} + +#[cfg(test)] +mod tests { + use super::*; + use vello_common::filter::gaussian_blur::{ + MAX_KERNEL_SIZE, compute_gaussian_kernel, plan_decimated_blur, + }; + + /// Test edge extension with Duplicate mode. + #[test] + fn test_extend_duplicate() { + let size = 10; + + // In-bounds coordinate + assert_eq!(extend(5, size, EdgeMode::Duplicate), 5); + + // Below bounds: clamp to 0 + assert_eq!(extend(-1, size, EdgeMode::Duplicate), 0); + assert_eq!(extend(-10, size, EdgeMode::Duplicate), 0); + + // Above bounds: clamp to size-1 + assert_eq!(extend(10, size, EdgeMode::Duplicate), 9); + assert_eq!(extend(20, size, EdgeMode::Duplicate), 9); + } + + /// Test edge extension with Wrap mode. + #[test] + fn test_extend_wrap() { + let size = 10; + + // In-bounds: identity + assert_eq!(extend(5, size, EdgeMode::Wrap), 5); + + // Above bounds: wrap around + assert_eq!(extend(10, size, EdgeMode::Wrap), 0); + assert_eq!(extend(11, size, EdgeMode::Wrap), 1); + assert_eq!(extend(25, size, EdgeMode::Wrap), 5); + + // Below bounds: wrap from end + assert_eq!(extend(-1, size, EdgeMode::Wrap), 9); + assert_eq!(extend(-2, size, EdgeMode::Wrap), 8); + } + + /// Test edge extension with Mirror mode. + #[test] + fn test_extend_mirror() { + let size = 10; + + // In-bounds: identity + assert_eq!(extend(5, size, EdgeMode::Mirror), 5); + + // Just past boundary: mirror back + // For coord=10: period=20, c=10, c>=10 so c = 20-10-1 = 9 + assert_eq!(extend(10, size, EdgeMode::Mirror), 9); + // For coord=11: period=20, c=11, c>=10 so c = 20-11-1 = 8 + assert_eq!(extend(11, size, EdgeMode::Mirror), 8); + + // Full reflection cycle (period = 2*size = 20) + // For coord=19: c=19, c>=10 so c = 20-19-1 = 0 + assert_eq!(extend(19, size, EdgeMode::Mirror), 0); + // For coord=20: c=20%20=0, c<10 so c=0 + assert_eq!(extend(20, size, EdgeMode::Mirror), 0); + + // Negative coordinates + // For coord=-1: c=-1%20=-1, c<0 so c+=20 → c=19, c>=10 so c=20-19-1=0 + assert_eq!(extend(-1, size, EdgeMode::Mirror), 0); + // For coord=-2: c=-2%20=-2, c<0 so c+=20 → c=18, c>=10 so c=20-18-1=1 + assert_eq!(extend(-2, size, EdgeMode::Mirror), 1); + } + + /// Test edge extension with None mode (coordinate should pass through). + #[test] + fn test_extend_none() { + let size = 10; + // None mode just passes the coordinate as u16 + assert_eq!(extend(5, size, EdgeMode::None), 5); + assert_eq!(extend(9, size, EdgeMode::None), 9); + assert_eq!(extend(10, size, EdgeMode::None), 10); + assert_eq!(extend(11, size, EdgeMode::None), 11); + } + + /// Test [1,3,3,1]/8 decimation weights. + #[test] + fn test_decimate_weighted() { + let p0 = PremulRgba8 { + r: 0, + g: 0, + b: 0, + a: 0, + }; + let p1 = PremulRgba8 { + r: 8, + g: 8, + b: 8, + a: 8, + }; + let p2 = PremulRgba8 { + r: 8, + g: 8, + b: 8, + a: 8, + }; + let p3 = PremulRgba8 { + r: 0, + g: 0, + b: 0, + a: 0, + }; + + // (0 + 3*8 + 3*8 + 0 + 4) / 8 = (48 + 4) / 8 = 52 / 8 = 6.5 → 6 (rounds down) + let result = decimate_weighted(p0, p1, p2, p3); + assert_eq!(result.r, 6); + assert_eq!(result.g, 6); + assert_eq!(result.b, 6); + assert_eq!(result.a, 6); + } + + /// Test [1,3,3,1]/8 with all same values (should be identity). + #[test] + fn test_decimate_weighted_uniform() { + let p = PremulRgba8 { + r: 100, + g: 150, + b: 200, + a: 255, + }; + let result = decimate_weighted(p, p, p, p); + // All same: (100 + 300 + 300 + 100 + 4) / 8 = 804/8 = 100.5 → 100 (rounds down) + assert_eq!(result.r, 100); + assert_eq!(result.g, 150); + assert_eq!(result.b, 200); + assert_eq!(result.a, 255); + } + + /// Test [0.25, 0.75] interpolation. + #[test] + fn test_interpolate_25_75() { + let p0 = PremulRgba8 { + r: 0, + g: 0, + b: 0, + a: 0, + }; + let p1 = PremulRgba8 { + r: 100, + g: 100, + b: 100, + a: 100, + }; + + // (0 + 300 + 2) / 4 = 302 / 4 = 75.5 → 75 (rounds down) + let result = interpolate_25_75(p0, p1); + assert_eq!(result.r, 75); + assert_eq!(result.g, 75); + assert_eq!(result.b, 75); + assert_eq!(result.a, 75); + } + + /// Test [0.75, 0.25] interpolation. + #[test] + fn test_interpolate_75_25() { + let p0 = PremulRgba8 { + r: 100, + g: 100, + b: 100, + a: 100, + }; + let p1 = PremulRgba8 { + r: 0, + g: 0, + b: 0, + a: 0, + }; + + // (300 + 0 + 2) / 4 = 302 / 4 = 75.5 → 75 (rounds down) + let result = interpolate_75_25(p0, p1); + assert_eq!(result.r, 75); + assert_eq!(result.g, 75); + assert_eq!(result.b, 75); + assert_eq!(result.a, 75); + } + + /// Test interpolation symmetry. + #[test] + fn test_interpolation_symmetry() { + let p0 = PremulRgba8 { + r: 50, + g: 100, + b: 150, + a: 200, + }; + let p1 = PremulRgba8 { + r: 200, + g: 150, + b: 100, + a: 50, + }; + + let r1 = interpolate_25_75(p0, p1); + let r2 = interpolate_75_25(p1, p0); + + // Should be symmetric + assert!((r1.r as i32 - r2.r as i32).abs() <= 0); + assert!((r1.g as i32 - r2.g as i32).abs() <= 0); + assert!((r1.b as i32 - r2.b as i32).abs() <= 0); + assert!((r1.a as i32 - r2.a as i32).abs() <= 0); + } + + /// Test that very small image sizes don't panic. + #[test] + fn test_small_image_sizes() { + let mut pixmap = Pixmap::new(1, 1); + let (n_decimations, kernel, kernel_size) = plan_decimated_blur(2.0); + + // Should not panic + let result = std::panic::catch_unwind(move || { + let mut scratch = Pixmap::new(1, 1); + apply_blur( + &mut pixmap, + &mut scratch, + n_decimations, + &kernel[..usize::from(kernel_size)], + EdgeMode::None, + ); + }); + + assert!(result.is_ok()); + } + + /// Test downscale with odd dimensions. + #[test] + fn test_downscale_odd_dimensions() { + let mut pixmap = Pixmap::new(5, 5); + // Fill with white + for y in 0..5 { + for x in 0..5 { + pixmap.set_pixel( + x, + y, + PremulRgba8 { + r: 255, + g: 255, + b: 255, + a: 255, + }, + ); + } + } + + let (new_width, new_height) = downscale(&mut pixmap, 5, 5, EdgeMode::Duplicate); + // 5 / 2 = 2.5 → ceil = 3 + assert_eq!(new_width, 3); + assert_eq!(new_height, 3); + } + + /// Test upscale dimensions. + #[test] + fn test_upscale_dimensions() { + let mut pixmap = Pixmap::new(6, 6); + let (new_width, new_height) = upscale(&mut pixmap, 3, 3, EdgeMode::Duplicate); + // 3 * 2 = 6 + assert_eq!(new_width, 6); + assert_eq!(new_height, 6); + } + + /// Test that horizontal convolution preserves uniform colors. + #[test] + fn test_convolve_x_uniform() { + let mut src = Pixmap::new(5, 3); + let mut dst = Pixmap::new(5, 3); + // Fill with uniform gray + src.data_mut().fill(PremulRgba8 { + r: 128, + g: 128, + b: 128, + a: 255, + }); + + let (kernel, kernel_size) = compute_gaussian_kernel(1.0); + convolve_x( + &src, + &mut dst, + 5, + 3, + &kernel[..usize::from(kernel_size)], + kernel_size / 2, + EdgeMode::Duplicate, + ); + + // Uniform input should produce uniform output + for y in 0..3 { + for x in 0..5 { + let pixel = dst.sample(x, y); + assert_eq!( + pixel, + PremulRgba8 { + r: 128, + g: 128, + b: 128, + a: 255, + } + ); + } + } + } + + /// Test that vertical convolution preserves uniform colors. + #[test] + fn test_convolve_y_uniform() { + let mut src = Pixmap::new(3, 5); + let mut dst = Pixmap::new(3, 5); + // Fill with uniform gray + src.data_mut().fill(PremulRgba8 { + r: 128, + g: 128, + b: 128, + a: 255, + }); + + let (kernel, kernel_size) = compute_gaussian_kernel(1.0); + convolve_y( + &src, + &mut dst, + 3, + 5, + &kernel[..usize::from(kernel_size)], + kernel_size / 2, + EdgeMode::Duplicate, + ); + + // Uniform input should produce uniform output + for y in 0..5 { + for x in 0..3 { + let pixel = dst.sample(x, y); + assert_eq!( + pixel, + PremulRgba8 { + r: 128, + g: 128, + b: 128, + a: 255, + } + ); + } + } + } + + /// Test that convolution with identity kernel is a no-op. + #[test] + fn test_convolve_identity_kernel() { + let mut src = Pixmap::new(3, 3); + let mut dst = Pixmap::new(3, 3); + src.set_pixel( + 1, + 1, + PremulRgba8 { + r: 255, + g: 100, + b: 50, + a: 200, + }, + ); + + let kernel = [1.0]; // Identity kernel + convolve_x(&src, &mut dst, 3, 3, &kernel, 0, EdgeMode::None); + + // Should be unchanged + assert_eq!(dst.sample(1, 1), src.sample(1, 1)); + } + + /// Test that large sigma values are clamped to `MAX_KERNEL_SIZE`. + #[test] + fn test_large_sigma_clamped_to_max() { + let (kernel, kernel_size) = compute_gaussian_kernel(100.0); + // For σ=100, radius = ceil(300) = 300, size would be 601 + // But it should be clamped to MAX_KERNEL_SIZE + assert_eq!(usize::from(kernel_size), MAX_KERNEL_SIZE); + + // The clamped kernel should still sum to 1.0 (normalized) + let sum: f32 = kernel.iter().take(usize::from(kernel_size)).sum(); + assert!((sum - 1.0).abs() < 1e-6); + } + + /// Test that decimation prevents kernel from exceeding `MAX_KERNEL_SIZE`. + #[test] + fn test_decimation_reduces_kernel_size() { + let (n_decimations, _kernel, kernel_size) = plan_decimated_blur(100.0); + assert_eq!(kernel_size, 9); + assert_eq!(n_decimations, 6, "Large sigma should trigger decimation"); + } + + /// Test sampling behavior at exact boundaries for each edge mode. + #[test] + fn test_sample_x_at_boundaries() { + let mut pixmap = Pixmap::new(3, 1); + pixmap.set_pixel( + 0, + 0, + PremulRgba8 { + r: 10, + g: 0, + b: 0, + a: 255, + }, + ); + pixmap.set_pixel( + 1, + 0, + PremulRgba8 { + r: 20, + g: 0, + b: 0, + a: 255, + }, + ); + pixmap.set_pixel( + 2, + 0, + PremulRgba8 { + r: 30, + g: 0, + b: 0, + a: 255, + }, + ); + + // Test left boundary with Duplicate + let mut p = sample_x(&pixmap, -1, 0, 3, EdgeMode::Duplicate); + assert_eq!(p.r, 10); // Should clamp to first pixel + + // Test right boundary with Duplicate + p = sample_x(&pixmap, 3, 0, 3, EdgeMode::Duplicate); + assert_eq!(p.r, 30); // Should clamp to last pixel + + // Test with None mode (should return transparent black) + p = sample_x(&pixmap, -1, 0, 3, EdgeMode::None); + assert_eq!(p.a, 0); + } + + /// Test sampling behavior at exact boundaries for vertical sampling. + #[test] + fn test_sample_y_at_boundaries() { + let mut pixmap = Pixmap::new(1, 3); + pixmap.set_pixel( + 0, + 0, + PremulRgba8 { + r: 10, + g: 0, + b: 0, + a: 255, + }, + ); + pixmap.set_pixel( + 0, + 1, + PremulRgba8 { + r: 20, + g: 0, + b: 0, + a: 255, + }, + ); + pixmap.set_pixel( + 0, + 2, + PremulRgba8 { + r: 30, + g: 0, + b: 0, + a: 255, + }, + ); + + // Test top boundary with Duplicate + let mut p = sample_y(&pixmap, 0, -1, 3, EdgeMode::Duplicate); + assert_eq!(p.r, 10); // Should clamp to first pixel + + // Test bottom boundary with Duplicate + p = sample_y(&pixmap, 0, 3, 3, EdgeMode::Duplicate); + assert_eq!(p.r, 30); // Should clamp to last pixel + + // Test with None mode (should return transparent black) + p = sample_y(&pixmap, 0, -1, 3, EdgeMode::None); + assert_eq!(p.a, 0); + } + + /// Test that kernel normalization is precise for various sigma values. + #[test] + fn test_kernel_normalization_precision() { + for sigma in [0.1, 0.5, 1.0, 2.0, 5.0, 10.0] { + let (kernel, kernel_size) = compute_gaussian_kernel(sigma); + let sum: f32 = kernel.iter().take(usize::from(kernel_size)).sum(); + assert!( + (sum - 1.0).abs() < 1e-6, + "Kernel for σ={} not normalized: sum={}", + sigma, + sum + ); + } + } + + /// Test that downscale → upscale preserves dimensions. + #[test] + fn test_downscale_upscale_roundtrip() { + let mut pixmap = Pixmap::new(8, 8); + // Fill with a pattern + pixmap.data_mut().fill(PremulRgba8 { + r: 128, + g: 128, + b: 128, + a: 255, + }); + + let (w1, h1) = downscale(&mut pixmap, 8, 8, EdgeMode::Duplicate); + assert_eq!(w1, 4); + assert_eq!(h1, 4); + + let (w2, h2) = upscale(&mut pixmap, w1, h1, EdgeMode::Duplicate); + assert_eq!(w2, 8); + assert_eq!(h2, 8); + } + + fn pixmap_from_red(width: u16, height: u16, values: &[&[u8]]) -> Pixmap { + let mut pixmap = Pixmap::new(width, height); + for (y, row) in values.iter().enumerate() { + for (x, &r) in row.iter().enumerate() { + pixmap.set_pixel( + x as u16, + y as u16, + PremulRgba8 { + r, + g: 0, + b: 0, + a: 255, + }, + ); + } + } + pixmap + } + + fn assert_red_values(pixmap: &Pixmap, expected: [[u8; W]; H]) { + for (y, row) in expected.iter().enumerate() { + for (x, &want) in row.iter().enumerate() { + let got = pixmap.sample(x as u16, y as u16).r; + + assert_eq!( + got, want, + "red mismatch at ({x}, {y}): got {got}, expected {want}" + ); + } + } + } + + #[test] + fn test_downscale_x_non_uniform() { + // (0,0)=0 (1,0)=40 (2,0)=80 (3,0)=120 + // (0,1)=20 (1,1)=60 (2,1)=100 (3,1)=140 + let mut pixmap = pixmap_from_red(4, 2, &[&[0, 40, 80, 120], &[20, 60, 100, 140]]); + + let dst_width = 4_u16.div_ceil(2); + downscale_x(&mut pixmap, 4, 2, dst_width, EdgeMode::Duplicate); + + // Row 0: p[-1]=0,p[0]=0,p[1]=40,p[2]=80 → (0+0*3+40*3+80+4)>>3 = 25 + // p[1]=40,p[2]=80,p[3]=120,p[4]=120 → (40+80*3+120*3+120+4)>>3 = 95 + // Row 1: p[-1]=20,p[0]=20,p[1]=60,p[2]=100 → (20+20*3+60*3+100+4)>>3 = 45 + // p[1]=60,p[2]=100,p[3]=140,p[4]=140 → (60+100*3+140*3+140+4)>>3 = 115 + assert_red_values(&pixmap, [[25, 95], [45, 115]]); + } + + #[test] + fn test_downscale_y_non_uniform() { + // Use a 2x4 image (output of a prior downscale_x). + // (0,0)=25 (1,0)=95 + // (0,1)=45 (1,1)=115 + // (0,2)=35 (1,2)=105 + // (0,3)=55 (1,3)=125 + let mut pixmap = pixmap_from_red(2, 4, &[&[25, 95], &[45, 115], &[35, 105], &[55, 125]]); + + let dst_height = 4_u16.div_ceil(2); + downscale_y(&mut pixmap, 2, 4, dst_height, EdgeMode::Duplicate); + + // Col 0: p[-1]=25,p[0]=25,p[1]=45,p[2]=35 → (25+25*3+45*3+35+4)>>3 = 34 + // p[1]=45,p[2]=35,p[3]=55,p[4]=55 → (45+35*3+55*3+55+4)>>3 = 46 + // Col 1: p[-1]=95,p[0]=95,p[1]=115,p[2]=105 → (95+95*3+115*3+105+4)>>3 = 104 + // p[1]=115,p[2]=105,p[3]=125,p[4]=125 → (115+105*3+125*3+125+4)>>3 = 116 + assert_red_values(&pixmap, [[34, 104], [46, 116]]); + } + + #[test] + fn test_upscale_x_non_uniform() { + let mut pixmap = pixmap_from_red(4, 2, &[&[34, 104], &[46, 116]]); + + upscale_x(&mut pixmap, 2, 2, EdgeMode::Duplicate); + + // Row 0 [34, 104]: + // x=0: interp25_75(34,34)=34, interp75_25(34,104)=(34*3+104+2)>>2 = 52 + // x=1: interp25_75(34,104)=(34+104*3+2)>>2=87, interp75_25(104,104)=104 + // Row 1 [46, 116]: + // x=0: 46, interp75_25(46,116)=(46*3+116+2)>>2=64 + // x=1: interp25_75(46,116)=(46+116*3+2)>>2=99, 116 + assert_red_values(&pixmap, [[34, 52, 87, 104], [46, 64, 99, 116]]); + } + + #[test] + fn test_upscale_y_non_uniform() { + let mut pixmap = pixmap_from_red(4, 4, &[&[34, 52, 87, 104], &[46, 64, 99, 116]]); + + upscale_y(&mut pixmap, 4, 2, EdgeMode::Duplicate); + + #[rustfmt::skip] + assert_red_values(&pixmap, [ + [ 34, 52, 87, 104], + [ 37, 55, 90, 107], + [ 43, 61, 96, 113], + [ 46, 64, 99, 116], + ]); + } +} diff --git a/vendor/vello_cpu/src/filter/mod.rs b/vendor/vello_cpu/src/filter/mod.rs new file mode 100644 index 0000000000..c0d56ccc13 --- /dev/null +++ b/vendor/vello_cpu/src/filter/mod.rs @@ -0,0 +1,120 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Filter effects implementation for `vello_cpu`. +//! +//! This module provides CPU-based implementations of SVG filter effects, +//! supporting both low-precision (u8) and high-precision (f32) rendering paths. +//! Filters are applied to layers through the layer manager, which handles +//! intermediate storage. + +mod drop_shadow; +mod flood; +mod gaussian_blur; +mod offset; +mod shift; + +use crate::layer_manager::LayerManager; +use vello_common::filter::PreparedFilter; +use vello_common::filter_effects::Filter; +use vello_common::kurbo::Affine; +use vello_common::pixmap::Pixmap; + +/// Trait for filter effects that can be applied to layers. +/// +/// Each filter implements this trait with both u8 and f32 variants +/// to support different rendering backends and precision requirements. +/// The low-precision path (`execute_lowp`) uses 8-bit color channels for +/// better performance, while the high-precision path (`execute_highp`) +/// uses 32-bit floating-point for higher quality at the cost of speed. +pub(crate) trait FilterEffect { + /// Apply the low-precision (u8) version of the filter. + /// + /// # Arguments + /// * `pixmap` - The target pixmap containing rendering metadata + /// * `layer_manager` - Manager for allocating and accessing intermediate layers + fn execute_lowp(&self, pixmap: &mut Pixmap, layer_manager: &mut LayerManager); + + /// Apply the high-precision (f32) version of the filter. + /// + /// # Arguments + /// * `pixmap` - The target pixmap containing rendering metadata + /// * `layer_manager` - Manager for allocating and accessing intermediate layers + fn execute_highp(&self, pixmap: &mut Pixmap, layer_manager: &mut LayerManager); +} + +/// Apply the low-precision (u8) version of a filter effect to a layer. +/// +/// This function dispatches filter primitives from a filter graph to their +/// corresponding CPU implementations using 8-bit color channels. +/// +/// # Arguments +/// * `filter` - The filter containing the graph of primitives to apply +/// * `pixmap` - The target pixmap containing rendering metadata +/// * `layer_manager` - Manager for allocating and accessing intermediate layers +/// * `transform` - The transformation matrix to extract scale from for filter parameters +/// +/// # Limitations +/// Currently only supports filter graphs with a single primitive. +/// Multi-primitive filter graphs are not yet implemented. +pub(crate) fn filter_lowp( + filter: &Filter, + pixmap: &mut Pixmap, + layer_manager: &mut LayerManager, + transform: Affine, +) { + let prepared_filter = PreparedFilter::new(filter, &transform); + + match prepared_filter { + PreparedFilter::Flood(flood) => { + flood.execute_lowp(pixmap, layer_manager); + } + PreparedFilter::GaussianBlur(blur) => { + blur.execute_lowp(pixmap, layer_manager); + } + PreparedFilter::Offset(offset) => { + offset.execute_lowp(pixmap, layer_manager); + } + PreparedFilter::DropShadow(drop_shadow) => { + drop_shadow.execute_lowp(pixmap, layer_manager); + } + } +} + +/// Apply the high-precision (f32) version of a filter effect to a layer. +/// +/// This function dispatches filter primitives from a filter graph to their +/// corresponding CPU implementations using 32-bit floating-point color channels. +/// +/// # Arguments +/// * `filter` - The filter containing the graph of primitives to apply +/// * `pixmap` - The target pixmap containing rendering metadata +/// * `layer_manager` - Manager for allocating and accessing intermediate layers +/// * `transform` - The transformation matrix to extract scale from for filter parameters +/// +/// # Limitations +/// Currently only supports filter graphs with a single primitive. +/// Multi-primitive filter graphs are not yet implemented. +pub(crate) fn filter_highp( + filter: &Filter, + pixmap: &mut Pixmap, + layer_manager: &mut LayerManager, + transform: Affine, +) { + let prepared_filter = PreparedFilter::new(filter, &transform); + + match prepared_filter { + PreparedFilter::Flood(flood) => { + flood.execute_highp(pixmap, layer_manager); + } + PreparedFilter::GaussianBlur(blur) => { + blur.execute_highp(pixmap, layer_manager); + } + PreparedFilter::Offset(offset) => { + offset.execute_highp(pixmap, layer_manager); + } + PreparedFilter::DropShadow(drop_shadow) => { + drop_shadow.execute_highp(pixmap, layer_manager); + } + } +} diff --git a/vendor/vello_cpu/src/filter/offset.rs b/vendor/vello_cpu/src/filter/offset.rs new file mode 100644 index 0000000000..e91120d59a --- /dev/null +++ b/vendor/vello_cpu/src/filter/offset.rs @@ -0,0 +1,44 @@ +// Copyright 2026 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! `feOffset` filter primitive implementation. + +use vello_common::filter::offset::Offset; +use vello_common::pixmap::Pixmap; + +use super::FilterEffect; +use super::shift::offset_pixels; +use crate::layer_manager::LayerManager; + +impl FilterEffect for Offset { + fn execute_lowp(&self, pixmap: &mut Pixmap, _: &mut LayerManager) { + offset_pixels(pixmap, self.dx, self.dy); + } + + fn execute_highp(&self, pixmap: &mut Pixmap, _: &mut LayerManager) { + offset_pixels(pixmap, self.dx, self.dy); + } +} + +#[cfg(test)] +mod tests { + use super::Offset; + use crate::filter::FilterEffect; + use crate::layer_manager::LayerManager; + use vello_common::peniko::color::PremulRgba8; + use vello_common::pixmap::Pixmap; + + #[test] + fn offset_moves_pixels_and_clears_uncovered_area() { + let mut layer_manager = LayerManager::new(); + let mut pixmap = Pixmap::new(4, 3); + pixmap.set_pixel(1, 1, PremulRgba8::from_u32(0xff_00_00_ff)); // premul red, opaque + + Offset::new(2.0, -1.0).execute_lowp(&mut pixmap, &mut layer_manager); + + // Original pixel (1,1) moved to (3,0). + assert_eq!(pixmap.sample(3, 0), PremulRgba8::from_u32(0xff_00_00_ff)); + // Original location cleared. + assert_eq!(pixmap.sample(1, 1), PremulRgba8::from_u32(0)); + } +} diff --git a/vendor/vello_cpu/src/filter/shift.rs b/vendor/vello_cpu/src/filter/shift.rs new file mode 100644 index 0000000000..82660794f9 --- /dev/null +++ b/vendor/vello_cpu/src/filter/shift.rs @@ -0,0 +1,253 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Shared helpers for pixel-space filter operations. + +use vello_common::color::palette::css::TRANSPARENT; +#[cfg(not(feature = "std"))] +use vello_common::kurbo::common::FloatFuncs as _; +use vello_common::peniko::color::PremulRgba8; +use vello_common::pixmap::Pixmap; + +/// Shift all pixels in a pixmap by the given offset. +/// +/// This implements the offset operation in-place by copying pixels to their new positions. +/// The iteration order is carefully chosen based on shift direction to avoid overwriting +/// source pixels before they're read. Areas that become exposed (due to the shift) are +/// filled with transparent black. Pixels that would move outside the bounds are discarded. +pub(crate) fn offset_pixels(pixmap: &mut Pixmap, dx: f32, dy: f32) { + let dx_pixels = dx.round() as i32; + let dy_pixels = dy.round() as i32; + + // Early return if no offset + if dx_pixels == 0 && dy_pixels == 0 { + return; + } + + let width = pixmap.width(); + let height = pixmap.height(); + let transparent = TRANSPARENT.premultiply().to_rgba8(); + + // Process pixels in the correct order to avoid overwriting source data. + // Key insight: iterate away from the direction of movement. + // This allows us to move pixels in-place without a temporary buffer. + // + // Use match to eliminate Box allocation overhead and enable + // better compiler optimization through static dispatch. + match (dx_pixels >= 0, dy_pixels >= 0) { + (true, true) => { + // Shift right+down: iterate bottom-to-top, right-to-left + for y in (0..height).rev() { + for x in (0..width).rev() { + process_offset_pixel( + pixmap, + x, + y, + dx_pixels, + dy_pixels, + width, + height, + transparent, + ); + } + } + } + (true, false) => { + // Shift right+up: iterate top-to-bottom, right-to-left + for y in 0..height { + for x in (0..width).rev() { + process_offset_pixel( + pixmap, + x, + y, + dx_pixels, + dy_pixels, + width, + height, + transparent, + ); + } + } + } + (false, true) => { + // Shift left+down: iterate bottom-to-top, left-to-right + for y in (0..height).rev() { + for x in 0..width { + process_offset_pixel( + pixmap, + x, + y, + dx_pixels, + dy_pixels, + width, + height, + transparent, + ); + } + } + } + (false, false) => { + // Shift left+up: iterate top-to-bottom, left-to-right + for y in 0..height { + for x in 0..width { + process_offset_pixel( + pixmap, + x, + y, + dx_pixels, + dy_pixels, + width, + height, + transparent, + ); + } + } + } + } +} + +/// Process a single pixel during offset operation. +/// +/// This moves the pixel to its new position (if in bounds) and clears the source +/// position if it's in the exposed region. +#[inline(always)] +fn process_offset_pixel( + pixmap: &mut Pixmap, + x: u16, + y: u16, + dx_pixels: i32, + dy_pixels: i32, + width: u16, + height: u16, + transparent: PremulRgba8, +) { + let new_x = x as i32 + dx_pixels; + let new_y = y as i32 + dy_pixels; + + if new_x >= 0 && new_x < width as i32 && new_y >= 0 && new_y < height as i32 { + let pixel = pixmap.sample(x, y); + pixmap.set_pixel(new_x as u16, new_y as u16, pixel); + } + + // Clear the source pixel if it's in the exposed region + let should_clear = (dx_pixels > 0 && x < dx_pixels as u16) + || (dx_pixels < 0 && x >= (width as i32 + dx_pixels) as u16) + || (dy_pixels > 0 && y < dy_pixels as u16) + || (dy_pixels < 0 && y >= (height as i32 + dy_pixels) as u16); + + if should_clear { + pixmap.set_pixel(x, y, transparent); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Test `offset_pixels` with positive offset (right and down). + #[test] + fn test_offset_pixels_positive() { + let mut pixmap = Pixmap::new(4, 4); + // Set center pixel to white + pixmap.set_pixel( + 1, + 1, + PremulRgba8 { + r: 255, + g: 255, + b: 255, + a: 255, + }, + ); + + offset_pixels(&mut pixmap, 1.0, 1.0); + + // White pixel should have moved from (1,1) to (2,2) + let moved = pixmap.sample(2, 2); + assert_eq!(moved.a, 255); + + // Original position should be cleared (in exposed region) + let cleared = pixmap.sample(1, 1); + assert_eq!(cleared.a, 0); + } + + /// Test `offset_pixels` with negative offset (left and up). + #[test] + fn test_offset_pixels_negative() { + let mut pixmap = Pixmap::new(4, 4); + // Set pixel at (2,2) to white + pixmap.set_pixel( + 2, + 2, + PremulRgba8 { + r: 255, + g: 255, + b: 255, + a: 255, + }, + ); + + offset_pixels(&mut pixmap, -1.0, -1.0); + + // White pixel should have moved from (2,2) to (1,1) + let moved = pixmap.sample(1, 1); + assert_eq!(moved.a, 255); + + // Original position should be cleared (in exposed region) + let cleared = pixmap.sample(2, 2); + assert_eq!(cleared.a, 0); + } + + /// Test `offset_pixels` with fractional offset (should round). + #[test] + fn test_offset_pixels_fractional() { + let mut pixmap = Pixmap::new(4, 4); + pixmap.set_pixel( + 1, + 1, + PremulRgba8 { + r: 255, + g: 255, + b: 255, + a: 255, + }, + ); + + // 0.6 rounds to 1, -0.4 rounds to 0 + offset_pixels(&mut pixmap, 0.6, -0.4); + + // Should move by (1, 0): from (1,1) to (2,1) + let moved = pixmap.sample(2, 1); + assert_eq!(moved.a, 255); + } + + /// Test `offset_pixels` with out-of-bounds offset (should clip). + #[test] + fn test_offset_pixels_out_of_bounds() { + let mut pixmap = Pixmap::new(4, 4); + pixmap.set_pixel( + 1, + 1, + PremulRgba8 { + r: 255, + g: 255, + b: 255, + a: 255, + }, + ); + + // Large offset that moves pixel outside bounds + offset_pixels(&mut pixmap, 10.0, 10.0); + + // Pixel moves outside, so (1,1) should be cleared + let cleared = pixmap.sample(1, 1); + assert_eq!(cleared.a, 0); + + // All pixels should be cleared (entire image is exposed region) + for y in 0..4 { + for x in 0..4 { + assert_eq!(pixmap.sample(x, y).a, 0); + } + } + } +} diff --git a/vendor/vello_cpu/src/fine/common/gradient/linear.rs b/vendor/vello_cpu/src/fine/common/gradient/linear.rs new file mode 100644 index 0000000000..c79d14b72f --- /dev/null +++ b/vendor/vello_cpu/src/fine/common/gradient/linear.rs @@ -0,0 +1,28 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use crate::fine::common::gradient::SimdGradientKind; +use core::marker::PhantomData; +use vello_common::encode::LinearKind; +use vello_common::fearless_simd::{Simd, f32x8}; + +#[derive(Debug)] +pub(crate) struct SimdLinearKind { + // For consistency with the other gradient types. + phantom_data: PhantomData, +} + +impl SimdLinearKind { + pub(crate) fn new(_: S, _: LinearKind) -> Self { + Self { + phantom_data: PhantomData, + } + } +} + +impl SimdGradientKind for SimdLinearKind { + #[inline(always)] + fn cur_pos(&self, x_pos: f32x8, _: f32x8) -> f32x8 { + x_pos + } +} diff --git a/vendor/vello_cpu/src/fine/common/gradient/mod.rs b/vendor/vello_cpu/src/fine/common/gradient/mod.rs new file mode 100644 index 0000000000..60cdbf8518 --- /dev/null +++ b/vendor/vello_cpu/src/fine/common/gradient/mod.rs @@ -0,0 +1,246 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use crate::fine::{NumericVec, PosExt}; +use crate::kurbo::Point; +use crate::peniko; +use core::slice::ChunksExact; +use vello_common::encode::{EncodedGradient, GradientLut}; +use vello_common::fearless_simd::*; + +pub(crate) mod linear; +pub(crate) mod radial; +pub(crate) mod sweep; + +const GRADIENT_INVALID_POS: u32 = u32::MAX; + +pub(crate) fn calculate_t_vals>( + simd: S, + kind: U, + buf: &mut [f32], + gradient: &EncodedGradient, + start_x: f64, + start_y: f64, +) { + simd.vectorize( + #[inline(always)] + || { + let mut cur_pos = gradient.transform * Point::new(start_x, start_y); + let x_advances = (gradient.x_advance.x as f32, gradient.x_advance.y as f32); + let y_advances = (gradient.y_advance.x as f32, gradient.y_advance.y as f32); + + for buf_part in buf.chunks_exact_mut(8) { + let x_pos = f32x8::splat_pos(simd, cur_pos.x as f32, x_advances.0, y_advances.0); + let y_pos = f32x8::splat_pos(simd, cur_pos.y as f32, x_advances.1, y_advances.1); + let pos = kind.cur_pos(x_pos, y_pos); + pos.store_slice(buf_part); + + cur_pos += 2.0 * gradient.x_advance; + } + }, + ); +} + +#[derive(Debug, Clone)] +pub(crate) struct GradientPainter<'a, S: Simd> { + gradient: &'a EncodedGradient, + lut: &'a GradientLut, + t_vals: ChunksExact<'a, f32>, + has_undefined: bool, + scale_factor: f32x8, + simd: S, +} + +impl<'a, S: Simd> GradientPainter<'a, S> { + pub(crate) fn new(simd: S, gradient: &'a EncodedGradient, t_vals: &'a [f32]) -> Self { + let lut = gradient.f32_lut(simd); + let scale_factor: f32x8 = f32x8::splat(simd, lut.scale_factor()); + + Self { + gradient, + scale_factor, + lut, + t_vals: t_vals.chunks_exact(8), + has_undefined: gradient.has_undefined, + simd, + } + } +} + +impl Iterator for GradientPainter<'_, S> { + type Item = u32x8; + + #[inline(always)] + fn next(&mut self) -> Option { + let extend = self.gradient.extend; + let pos = f32x8::from_slice(self.simd, self.t_vals.next()?); + let t_vals = apply_extend(pos, extend); + let valid = pos.simd_eq(pos); + let indices = (t_vals * self.scale_factor).to_int::>(); + // In case we had any NaN's, set the index to an explicit invalid sentinel. There + // probably is architecture-specific behavior how NaN will be converted to + // f32, so we apply our own handling of that. + let indices = self.simd.select_u32x8( + valid, + indices, + u32x8::splat(self.simd, GRADIENT_INVALID_POS), + ); + + Some(indices) + } +} + +impl crate::fine::Painter for GradientPainter<'_, S> { + fn paint_u8(&mut self, buf: &mut [u8]) { + let max_index = self.lut.width() as u32 - 1; + let mut masked_iter = self.has_undefined.then_some(self.clone()); + + self.simd.vectorize( + #[inline(always)] + || { + let max_index = u32x8::splat(self.simd, max_index); + + for chunk in buf.chunks_exact_mut(32) { + let indices = self.next().unwrap(); + let clamped_indices = indices.min(max_index); + + let rgbas_1: [f32x4; 4] = core::array::from_fn(|i| { + let idx = clamped_indices[i] as usize; + f32x4::from_slice(self.simd, &self.lut.get(idx)) + }); + let rgbas_1 = self.simd.combine_f32x8( + self.simd.combine_f32x4(rgbas_1[0], rgbas_1[1]), + self.simd.combine_f32x4(rgbas_1[2], rgbas_1[3]), + ); + let rgbas_1 = u8x16::from_f32(self.simd, rgbas_1); + rgbas_1.store_slice(&mut chunk[..16]); + + let rgbas_2: [f32x4; 4] = core::array::from_fn(|i| { + let idx = clamped_indices[i + 4] as usize; + f32x4::from_slice(self.simd, &self.lut.get(idx)) + }); + let rgbas_2 = self.simd.combine_f32x8( + self.simd.combine_f32x4(rgbas_2[0], rgbas_2[1]), + self.simd.combine_f32x4(rgbas_2[2], rgbas_2[3]), + ); + let rgbas_2 = u8x16::from_f32(self.simd, rgbas_2); + rgbas_2.store_slice(&mut chunk[16..]); + } + }, + ); + + // Mask any potential NaN positions. + if let Some(mut masked_iter) = masked_iter.take() { + self.simd.vectorize( + #[inline(always)] + || { + for chunk in buf.chunks_exact_mut(32) { + let indices = masked_iter.next().unwrap(); + let invalid = + indices.simd_eq(u32x8::splat(self.simd, GRADIENT_INVALID_POS)); + let (invalid_1, invalid_2) = self.simd.split_mask32x8(invalid); + + let loaded_1 = self + .simd + .reinterpret_u32_u8x16(u8x16::from_slice(self.simd, &chunk[..16])); + let masked_1 = + self.simd + .select_u32x4(invalid_1, u32x4::splat(self.simd, 0), loaded_1); + self.simd + .reinterpret_u8_u32x4(masked_1) + .store_slice(&mut chunk[..16]); + + let loaded_2 = self + .simd + .reinterpret_u32_u8x16(u8x16::from_slice(self.simd, &chunk[16..])); + let masked_2 = + self.simd + .select_u32x4(invalid_2, u32x4::splat(self.simd, 0), loaded_2); + self.simd + .reinterpret_u8_u32x4(masked_2) + .store_slice(&mut chunk[16..]); + } + }, + ); + } + } + + fn paint_f32(&mut self, buf: &mut [f32]) { + let mut masked_iter = self.has_undefined.then_some(self.clone()); + + self.simd.vectorize( + #[inline(always)] + || { + let max_index = self.lut.width() as u32 - 1; + let max_index = u32x8::splat(self.simd, max_index); + + for chunk in buf.chunks_exact_mut(32) { + let indices = self.next().unwrap(); + let clamped_indices = indices.min(max_index); + chunk[0..4].copy_from_slice(&self.lut.get(clamped_indices[0] as usize)); + chunk[4..8].copy_from_slice(&self.lut.get(clamped_indices[1] as usize)); + chunk[8..12].copy_from_slice(&self.lut.get(clamped_indices[2] as usize)); + chunk[12..16].copy_from_slice(&self.lut.get(clamped_indices[3] as usize)); + chunk[16..20].copy_from_slice(&self.lut.get(clamped_indices[4] as usize)); + chunk[20..24].copy_from_slice(&self.lut.get(clamped_indices[5] as usize)); + chunk[24..28].copy_from_slice(&self.lut.get(clamped_indices[6] as usize)); + chunk[28..32].copy_from_slice(&self.lut.get(clamped_indices[7] as usize)); + } + }, + ); + + // Mask any potential NaN positions. + if let Some(mut masked_iter) = masked_iter.take() { + self.simd.vectorize( + #[inline(always)] + || { + for chunk in buf.chunks_exact_mut(32) { + let indices = masked_iter.next().unwrap(); + let (indices_1, indices_2) = self.simd.split_u32x8(indices); + let invalid_1 = invalid_f32_mask(self.simd, indices_1); + let loaded_1 = f32x16::from_slice(self.simd, &chunk[..16]); + let masked_1 = self.simd.select_f32x16( + invalid_1, + f32x16::splat(self.simd, 0.0), + loaded_1, + ); + masked_1.store_slice(&mut chunk[..16]); + + let invalid_2 = invalid_f32_mask(self.simd, indices_2); + let loaded_2 = f32x16::from_slice(self.simd, &chunk[16..]); + let masked_2 = self.simd.select_f32x16( + invalid_2, + f32x16::splat(self.simd, 0.0), + loaded_2, + ); + masked_2.store_slice(&mut chunk[16..]); + } + }, + ); + } + } +} + +#[inline(always)] +fn invalid_f32_mask(simd: S, indices: u32x4) -> mask32x16 { + let indices = indices.zip_low(indices).combine(indices.zip_high(indices)); + let indices = indices.zip_low(indices).combine(indices.zip_high(indices)); + indices.simd_eq(u32x16::splat(simd, GRADIENT_INVALID_POS)) +} + +#[inline(always)] +pub(crate) fn apply_extend(val: f32x8, extend: peniko::Extend) -> f32x8 { + match extend { + peniko::Extend::Pad => val.max(0.0).min(1.0), + peniko::Extend::Repeat => (val - val.floor()).fract(), + // See + peniko::Extend::Reflect => ((val - 1.0) - 2.0 * ((val - 1.0) * 0.5).floor() - 1.0) + .abs() + .max(0.0) + .min(1.0), + } +} + +pub(crate) trait SimdGradientKind { + fn cur_pos(&self, x_pos: f32x8, y_pos: f32x8) -> f32x8; +} diff --git a/vendor/vello_cpu/src/fine/common/gradient/radial.rs b/vendor/vello_cpu/src/fine/common/gradient/radial.rs new file mode 100644 index 0000000000..78ce5fb648 --- /dev/null +++ b/vendor/vello_cpu/src/fine/common/gradient/radial.rs @@ -0,0 +1,107 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use crate::fine::common::gradient::SimdGradientKind; +use vello_common::encode::{FocalData, RadialKind}; +use vello_common::fearless_simd::{Simd, SimdBase, SimdFloat, f32x8}; + +pub(crate) enum SimdRadialKindInner { + Radial { + bias: f32x8, + scale: f32x8, + }, + Strip { + scaled_r0_squared: f32x8, + }, + Focal { + focal_data: FocalData, + fp0: f32x8, + fp1: f32x8, + }, +} + +pub(crate) struct SimdRadialKind { + inner: SimdRadialKindInner, +} + +impl SimdRadialKind { + pub(crate) fn new(simd: S, kind: &RadialKind) -> Self { + let inner = match kind { + RadialKind::Radial { bias, scale } => SimdRadialKindInner::Radial { + bias: f32x8::splat(simd, *bias), + scale: f32x8::splat(simd, *scale), + }, + RadialKind::Strip { scaled_r0_squared } => SimdRadialKindInner::Strip { + scaled_r0_squared: f32x8::splat(simd, *scaled_r0_squared), + }, + RadialKind::Focal { + focal_data, + fp0, + fp1, + } => SimdRadialKindInner::Focal { + fp0: f32x8::splat(simd, *fp0), + fp1: f32x8::splat(simd, *fp1), + focal_data: *focal_data, + }, + }; + + Self { inner } + } +} + +impl SimdGradientKind for SimdRadialKind { + #[inline(always)] + fn cur_pos(&self, x_pos: f32x8, y_pos: f32x8) -> f32x8 { + let simd = x_pos.simd; + + match &self.inner { + SimdRadialKindInner::Radial { bias, scale } => { + let radius = (x_pos * x_pos + y_pos * y_pos).sqrt(); + + *bias + radius * *scale + } + SimdRadialKindInner::Strip { scaled_r0_squared } => { + let p1 = *scaled_r0_squared - y_pos * y_pos; + + let mask = simd.simd_lt_f32x8(p1, f32x8::splat(simd, 0.0)); + simd.select_f32x8(mask, f32x8::splat(simd, f32::NAN), x_pos + p1.sqrt()) + } + SimdRadialKindInner::Focal { + focal_data, + fp0, + fp1, + } => { + let mut t = if focal_data.is_focal_on_circle() { + x_pos + y_pos * y_pos / x_pos + } else if focal_data.is_well_behaved() { + (x_pos * x_pos + y_pos * y_pos).sqrt() - x_pos * *fp0 + } else if focal_data.is_swapped() || (1.0 - focal_data.f_focal_x < 0.0) { + f32x8::splat(simd, -1.0) * (x_pos * x_pos - y_pos * y_pos).sqrt() - x_pos * *fp0 + } else { + (x_pos * x_pos - y_pos * y_pos).sqrt() - x_pos * *fp0 + }; + + if !focal_data.is_well_behaved() { + // Radii < 0 should be masked out, too. + let is_degenerate = simd.simd_le_f32x8(t, f32x8::splat(simd, 0.0)); + + t = simd.select_f32x8(is_degenerate, f32x8::splat(simd, f32::NAN), t); + } + + if 1.0 - focal_data.f_focal_x < 0.0 { + t = f32x8::splat(simd, -1.0) * t; + } + + if !focal_data.is_natively_focal() { + t += *fp1; + } + + if focal_data.is_swapped() { + t = f32x8::splat(simd, 1.0) - t; + } + + t + } + } + } +} diff --git a/vendor/vello_cpu/src/fine/common/gradient/sweep.rs b/vendor/vello_cpu/src/fine/common/gradient/sweep.rs new file mode 100644 index 0000000000..02a0a401ff --- /dev/null +++ b/vendor/vello_cpu/src/fine/common/gradient/sweep.rs @@ -0,0 +1,61 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use crate::fine::common::gradient::SimdGradientKind; +use core::f32::consts::PI; +use vello_common::encode::SweepKind; +use vello_common::fearless_simd::{Simd, SimdBase, SimdFloat, f32x8}; + +#[derive(Debug)] +pub(crate) struct SimdSweepKind { + start_angle: f32x8, + inv_angle_delta: f32x8, + simd: S, +} + +impl SimdSweepKind { + pub(crate) fn new(simd: S, kind: &SweepKind) -> Self { + Self { + start_angle: f32x8::splat(simd, kind.start_angle), + inv_angle_delta: f32x8::splat(simd, kind.inv_angle_delta), + simd, + } + } +} + +impl SimdGradientKind for SimdSweepKind { + #[inline(always)] + fn cur_pos(&self, x_pos: f32x8, y_pos: f32x8) -> f32x8 { + let angle = x_y_to_unit_angle(self.simd, x_pos, y_pos) * f32x8::splat(self.simd, 2.0 * PI); + + (angle - self.start_angle) * self.inv_angle_delta + } +} + +#[inline(always)] +fn x_y_to_unit_angle(simd: S, x: f32x8, y: f32x8) -> f32x8 { + let c0 = f32x8::splat(simd, 0.0); + let c1 = f32x8::splat(simd, 1.0); + let c2 = f32x8::splat(simd, 1.0 / 4.0); + let c3 = f32x8::splat(simd, 1.0 / 2.0); + + let x_abs = x.abs(); + let y_abs = y.abs(); + + let slope = x_abs.min(y_abs) / x_abs.max(y_abs); + let s = slope * slope; + + let a = f32x8::splat(simd, -7.054_738_2e-3).mul_add(s, f32x8::splat(simd, 2.476_102e-2)); + let b = a.mul_add(s, f32x8::splat(simd, -5.185_397e-2)); + let c = b.mul_add(s, f32x8::splat(simd, 0.159_121_17)); + + let mut phi = slope * c; + + phi = simd.select_f32x8(simd.simd_lt_f32x8(x_abs, y_abs), c2 - phi, phi); + phi = simd.select_f32x8(simd.simd_lt_f32x8(x, c0), c3 - phi, phi); + phi = simd.select_f32x8(simd.simd_lt_f32x8(y, c0), c1 - phi, phi); + // Clears all NaNs, using the property that NaN != NaN. + phi = simd.select_f32x8(phi.simd_eq(phi), phi, c0); + + phi +} diff --git a/vendor/vello_cpu/src/fine/common/image.rs b/vendor/vello_cpu/src/fine/common/image.rs new file mode 100644 index 0000000000..dad257b071 --- /dev/null +++ b/vendor/vello_cpu/src/fine/common/image.rs @@ -0,0 +1,568 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use crate::fine::macros::{f32x16_painter, u8x16_painter}; +use crate::fine::{PosExt, Splat4thExt, u8_to_f32}; +use crate::kurbo::Point; +use vello_common::encode::EncodedImage; +use vello_common::fearless_simd::{Bytes, Simd, SimdBase, SimdFloat, f32x4, f32x16, u8x16, u32x4}; +use vello_common::pixmap::Pixmap; +use vello_common::simd::element_wise_splat; + +/// A painter for nearest-neighbor images with no skewing. +#[derive(Debug)] +pub(crate) struct PlainNNImagePainter<'a, S: Simd> { + data: ImagePainterData<'a, S>, + y_positions: f32x4, + cur_x_pos: f32x4, + advance: f32, + simd: S, +} + +impl<'a, S: Simd> PlainNNImagePainter<'a, S> { + pub(crate) fn new( + simd: S, + image: &'a EncodedImage, + pixmap: &'a Pixmap, + start_x: f64, + start_y: f64, + ) -> Self { + let data = ImagePainterData::new(simd, image, pixmap, start_x, start_y); + + let y_positions = extend( + simd, + f32x4::splat_pos( + simd, + data.cur_pos.y as f32, + data.x_advances.1, + data.y_advances.1, + ), + image.sampler.y_extend, + data.height, + data.height_inv, + ); + + let cur_x_pos = f32x4::splat_pos( + simd, + data.cur_pos.x as f32, + data.x_advances.0, + data.y_advances.0, + ); + + Self { + data, + advance: image.x_advance.x as f32, + y_positions, + cur_x_pos, + simd, + } + } +} + +impl Iterator for PlainNNImagePainter<'_, S> { + type Item = u8x16; + + #[inline(always)] + fn next(&mut self) -> Option { + let x_pos = extend( + self.simd, + self.cur_x_pos, + self.data.image.sampler.x_extend, + self.data.width, + self.data.width_inv, + ); + + let samples = sample(self.simd, &self.data, x_pos, self.y_positions); + + self.cur_x_pos += self.advance; + + Some(samples) + } +} + +u8x16_painter!(PlainNNImagePainter<'_, S>); + +/// A painter for nearest-neighbor images with arbitrary transforms. +#[derive(Debug)] +pub(crate) struct NNImagePainter<'a, S: Simd> { + data: ImagePainterData<'a, S>, + simd: S, +} + +impl<'a, S: Simd> NNImagePainter<'a, S> { + pub(crate) fn new( + simd: S, + image: &'a EncodedImage, + pixmap: &'a Pixmap, + start_x: f64, + start_y: f64, + ) -> Self { + let data = ImagePainterData::new(simd, image, pixmap, start_x, start_y); + + Self { data, simd } + } +} + +impl Iterator for NNImagePainter<'_, S> { + type Item = u8x16; + + fn next(&mut self) -> Option { + let x_positions = extend( + self.simd, + f32x4::splat_pos( + self.simd, + self.data.cur_pos.x as f32, + self.data.x_advances.0, + self.data.y_advances.0, + ), + self.data.image.sampler.x_extend, + self.data.width, + self.data.width_inv, + ); + + let y_positions = extend( + self.simd, + f32x4::splat_pos( + self.simd, + self.data.cur_pos.y as f32, + self.data.x_advances.1, + self.data.y_advances.1, + ), + self.data.image.sampler.y_extend, + self.data.height, + self.data.height_inv, + ); + + let samples = sample(self.simd, &self.data, x_positions, y_positions); + + self.data.cur_pos += self.data.image.x_advance; + + Some(samples) + } +} + +u8x16_painter!(NNImagePainter<'_, S>); + +/// A painter for images with bilinear or bicubic filtering. +/// +/// The painter is generic over sampler quality using the const-generic `QUALITY` parameter. +/// +/// - Set `QUALITY` to `1` for bilinear sampling; or +/// - set `QUALITY` to `2` for bicubic sampling. +/// +/// These values for `QUALITY` are the same numeric values as defined by +/// [`crate::peniko::ImageQuality`]. +#[derive(Debug)] +pub(crate) struct FilteredImagePainter<'a, S: Simd, const QUALITY: u8> { + data: ImagePainterData<'a, S>, + simd: S, +} + +impl<'a, S: Simd, const QUALITY: u8> FilteredImagePainter<'a, S, QUALITY> { + pub(crate) fn new( + simd: S, + image: &'a EncodedImage, + pixmap: &'a Pixmap, + start_x: f64, + start_y: f64, + ) -> Self { + let data = ImagePainterData::new(simd, image, pixmap, start_x, start_y); + + Self { data, simd } + } +} + +impl Iterator for FilteredImagePainter<'_, S, QUALITY> { + type Item = f32x16; + + fn next(&mut self) -> Option { + let x_positions = f32x4::splat_pos( + self.simd, + self.data.cur_pos.x as f32, + self.data.x_advances.0, + self.data.y_advances.0, + ); + + let y_positions = f32x4::splat_pos( + self.simd, + self.data.cur_pos.y as f32, + self.data.x_advances.1, + self.data.y_advances.1, + ); + + // We have two versions of filtering: `Medium` (bilinear filtering) and + // `High` (bicubic filtering). + + // In bilinear filtering, we sample the pixels of the rectangle that spans the + // locations (-0.5, -0.5) and (0.5, 0.5), and weight them by the fractional + // x/y position using simple linear interpolation in both dimensions. + // In bicubic filtering, we instead span a 4x4 grid around the + // center of the location we are sampling, and sample those points + // using a cubic filter to weight each location's contribution. + + let x_fract = fract_floor(x_positions + 0.5); + let y_fract = fract_floor(y_positions + 0.5); + + let mut interpolated_color = f32x16::splat(self.simd, 0.0); + + let sample = |x_pos: f32x4, y_pos: f32x4| { + u8_to_f32(sample(self.simd, &self.data, x_pos, y_pos)) + }; + + macro_rules! extend_x { + ($idx:expr,$offsets:expr) => { + extend( + self.simd, + x_positions + $offsets[$idx], + self.data.image.sampler.y_extend, + self.data.width, + self.data.width_inv, + ) + }; + } + + macro_rules! extend_y { + ($idx:expr,$offsets:expr) => { + extend( + self.simd, + y_positions + $offsets[$idx], + self.data.image.sampler.y_extend, + self.data.height, + self.data.height_inv, + ) + }; + } + + match QUALITY { + // medium quality: bilinear + 1 => { + // + let cx = [1.0 - x_fract, x_fract]; + let cy = [1.0 - y_fract, y_fract]; + + // Note that the sum of all cx*cy combinations also yields 1.0 again + // (modulo some floating point number impreciseness), ensuring the + // colors stay in range. + + const OFFSETS: [f32; 2] = [-0.5, 0.5]; + + let x_positions = [extend_x!(0, OFFSETS), extend_x!(1, OFFSETS)]; + + let y_positions = [extend_y!(0, OFFSETS), extend_y!(1, OFFSETS)]; + + // We sample the corners of rectangle that covers our current position. + for x_idx in 0..2 { + let x_positions = x_positions[x_idx]; + + for y_idx in 0..2 { + let y_positions = y_positions[y_idx]; + let color_sample = sample(x_positions, y_positions); + let w = element_wise_splat(self.simd, cx[x_idx] * cy[y_idx]); + + interpolated_color = w.mul_add(color_sample, interpolated_color); + } + } + + interpolated_color *= f32x16::splat(self.simd, 1.0 / 255.0); + } + // high quality: bicubic + 2 => { + // Compare to . + let cx = weights(self.simd, x_fract); + let cy = weights(self.simd, y_fract); + + const OFFSETS: [f32; 4] = [-1.5, -0.5, 0.5, 1.5]; + + let x_positions = [ + extend_x!(0, OFFSETS), + extend_x!(1, OFFSETS), + extend_x!(2, OFFSETS), + extend_x!(3, OFFSETS), + ]; + + let y_positions = [ + extend_y!(0, OFFSETS), + extend_y!(1, OFFSETS), + extend_y!(2, OFFSETS), + extend_y!(3, OFFSETS), + ]; + + // Note in particular that it is guaranteed that, similarly to bilinear filtering, + // the sum of all cx*cy is 1 (modulo some edge cases). + + // We sample the 4x4 grid around the position we are currently looking at. + for x_idx in 0..4 { + let x_positions = x_positions[x_idx]; + for y_idx in 0..4 { + let y_positions = y_positions[y_idx]; + + let color_sample = sample(x_positions, y_positions); + let w = element_wise_splat(self.simd, cx[x_idx] * cy[y_idx]); + + interpolated_color = w.mul_add(color_sample, interpolated_color); + } + } + + interpolated_color *= f32x16::splat(self.simd, 1.0 / 255.0); + + let alphas = interpolated_color.splat_4th(); + + // Due to the nature of the cubic filter, it can happen in certain situations + // that one of the color components ends up with a higher value than the + // alpha component, which isn't permissible because the color is + // premultiplied and would lead to overflows when doing source over + // compositing with u8-based values. Because of this, we need to clamp + // to the alpha value. + interpolated_color = interpolated_color + .min(alphas) + .min(f32x16::splat(self.simd, 1.0)) + .max(f32x16::splat(self.simd, 0.0)); + } + _ => panic!( + "Unknown value for `FilteredImagePainter`'s const-generic `QUALITY` parameter. Expected `1` for bilinear or `2` for bicubic, got: `{QUALITY}`." + ), + } + + self.data.cur_pos += self.data.image.x_advance; + + Some(interpolated_color) + } +} + +// Bilinear +f32x16_painter!(FilteredImagePainter<'_, S, 1>); +// Bicubic +f32x16_painter!(FilteredImagePainter<'_, S, 2>); + +/// Computes the positive fractional part of a value: `val - val.floor()`. +/// +/// Unlike `f32::fract()`, this always returns a value in [0, 1), +/// even for negative inputs. +#[inline(always)] +pub(crate) fn fract_floor(val: f32x4) -> f32x4 { + val - val.floor() +} + +/// Common data used by different image painters +#[derive(Debug)] +pub(crate) struct ImagePainterData<'a, S: Simd> { + pub(crate) cur_pos: Point, + pub(crate) image: &'a EncodedImage, + pub(crate) pixmap: &'a Pixmap, + pub(crate) x_advances: (f32, f32), + pub(crate) y_advances: (f32, f32), + pub(crate) height: f32x4, + pub(crate) height_inv: f32x4, + pub(crate) width: f32x4, + pub(crate) width_inv: f32x4, + pub(crate) width_u32: u32x4, +} + +impl<'a, S: Simd> ImagePainterData<'a, S> { + pub(crate) fn new( + simd: S, + image: &'a EncodedImage, + pixmap: &'a Pixmap, + start_x: f64, + start_y: f64, + ) -> Self { + let width = pixmap.width() as f32; + let height = pixmap.height() as f32; + let start_pos = image.transform * Point::new(start_x, start_y); + + let width_inv = f32x4::splat(simd, 1.0 / width); + let height_inv = f32x4::splat(simd, 1.0 / height); + let width = f32x4::splat(simd, width); + let width_u32 = u32x4::splat(simd, pixmap.width() as u32); + let height = f32x4::splat(simd, height); + + let x_advances = (image.x_advance.x as f32, image.x_advance.y as f32); + let y_advances = (image.y_advance.x as f32, image.y_advance.y as f32); + + Self { + cur_pos: start_pos, + pixmap, + x_advances, + y_advances, + image, + width, + height, + width_u32, + width_inv, + height_inv, + } + } +} + +#[inline(always)] +pub(crate) fn sample( + simd: S, + data: &ImagePainterData<'_, S>, + x_positions: f32x4, + y_positions: f32x4, +) -> u8x16 { + let idx = x_positions.to_int::>() + y_positions.to_int::>() * data.width_u32; + + u32x4::from_slice( + simd, + &[ + data.pixmap.sample_idx(idx[0]).to_u32(), + data.pixmap.sample_idx(idx[1]).to_u32(), + data.pixmap.sample_idx(idx[2]).to_u32(), + data.pixmap.sample_idx(idx[3]).to_u32(), + ], + ) + .to_bytes() +} + +#[inline(always)] +pub(crate) fn extend( + simd: S, + val: f32x4, + extend: crate::peniko::Extend, + max: f32x4, + inv_max: f32x4, +) -> f32x4 { + match extend { + // Note that max should be exclusive, so subtract one to enforce that. + // Since the maximum image dimensions we support is u16::MAX, subtracting 1 in f32 + // is enough to ensure that all numbers are subtracted correctly. + crate::peniko::Extend::Pad => val.min(max - 1.0).max(f32x4::splat(simd, 0.0)), + crate::peniko::Extend::Repeat => { + // floor := (val * inv_max).floor() * max is the nearest multiple of `max` below val. + max.mul_add(-(val * inv_max).floor(), val) + // In certain edge cases, we might still end up with a higher number. + .min(max - 1.0) + } + // + crate::peniko::Extend::Reflect => { + let u = val + - (val * inv_max * f32x4::splat(simd, 0.5)).floor() * f32x4::splat(simd, 2.0) * max; + let s = (u * inv_max).floor(); + let m = u - f32x4::splat(simd, 2.0) * s * (u - max); + + let bias_in_ulps = s.trunc(); + + let m_bits = u32x4::from_bytes(m.to_bytes()); + // This would yield NaN if `m` is 0 and `bias_in_ulps` > 0, but since + // our `max` is always an integer number, u and s must also be an integer number + // and thus `m_bits` must be 0. + // Note that this is a wrapping sub! + let biased_bits = m_bits - bias_in_ulps.to_int::>(); + f32x4::from_bytes(biased_bits.to_bytes()) + // In certain edge cases, we might still end up with a higher number. + .min(max - 1.0) + } + } +} + +/// Calculate the weights for a single fractional value. +fn weights(simd: S, fract: f32x4) -> [f32x4; 4] { + simd.vectorize( + #[inline(always)] + || { + let s = fract.simd; + const MF: [[f32; 4]; 4] = mf_resampler(); + + [ + single_weight( + fract, + f32x4::splat(s, MF[0][0]), + f32x4::splat(s, MF[0][1]), + f32x4::splat(s, MF[0][2]), + f32x4::splat(s, MF[0][3]), + ), + single_weight( + fract, + f32x4::splat(s, MF[1][0]), + f32x4::splat(s, MF[1][1]), + f32x4::splat(s, MF[1][2]), + f32x4::splat(s, MF[1][3]), + ), + single_weight( + fract, + f32x4::splat(s, MF[2][0]), + f32x4::splat(s, MF[2][1]), + f32x4::splat(s, MF[2][2]), + f32x4::splat(s, MF[2][3]), + ), + single_weight( + fract, + f32x4::splat(s, MF[3][0]), + f32x4::splat(s, MF[3][1]), + f32x4::splat(s, MF[3][2]), + f32x4::splat(s, MF[3][3]), + ), + ] + }, + ) +} + +/// Calculate a weight based on the fractional value t and the cubic coefficients. +#[inline(always)] +fn single_weight( + t: f32x4, + a: f32x4, + b: f32x4, + c: f32x4, + d: f32x4, +) -> f32x4 { + t.mul_add(d, c).mul_add(t, b).mul_add(t, a) +} + +/// Mitchell filter with the variables B = 1/3 and C = 1/3. +const fn mf_resampler() -> [[f32; 4]; 4] { + cubic_resampler(1.0 / 3.0, 1.0 / 3.0) +} + +/// Cubic resampling logic is borrowed from Skia. See +/// +/// for some links to understand how this works. In principle, this macro allows us to define a +/// resampler kernel based on two variables B and C which can be between 0 and 1, allowing to +/// change some properties of the cubic interpolation kernel. +/// +/// As mentioned above, cubic resampling consists of sampling the 16 surrounding pixels of the +/// target point and interpolating them with a cubic filter. +/// The generated matrix is 4x4 and represent the coefficients of the cubic function used to +/// calculate weights based on the `x_fract` and `y_fract` of the location we are looking at. +const fn cubic_resampler(b: f32, c: f32) -> [[f32; 4]; 4] { + [ + [ + (1.0 / 6.0) * b, + -(3.0 / 6.0) * b - c, + (3.0 / 6.0) * b + 2.0 * c, + -(1.0 / 6.0) * b - c, + ], + [ + 1.0 - (2.0 / 6.0) * b, + 0.0, + -3.0 + (12.0 / 6.0) * b + c, + 2.0 - (9.0 / 6.0) * b - c, + ], + [ + (1.0 / 6.0) * b, + (3.0 / 6.0) * b + c, + 3.0 - (15.0 / 6.0) * b - 2.0 * c, + -2.0 + (9.0 / 6.0) * b + c, + ], + [0.0, 0.0, -c, (1.0 / 6.0) * b + c], + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use vello_common::fearless_simd::Fallback; + + #[test] + fn extend_overflow() { + let simd = Fallback::new(); + let max = f32x4::splat(simd, 128.0); + let max_inv = 1.0 / max; + + let num = f32x4::splat(simd, 127.00001); + let res = extend(simd, num, crate::peniko::Extend::Repeat, max, max_inv); + + assert!(res[0] <= 127.0); + } +} diff --git a/vendor/vello_cpu/src/fine/common/mod.rs b/vendor/vello_cpu/src/fine/common/mod.rs new file mode 100644 index 0000000000..8b19b8e9d3 --- /dev/null +++ b/vendor/vello_cpu/src/fine/common/mod.rs @@ -0,0 +1,6 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +pub(crate) mod gradient; +pub(crate) mod image; +pub(crate) mod rounded_blurred_rect; diff --git a/vendor/vello_cpu/src/fine/common/rounded_blurred_rect.rs b/vendor/vello_cpu/src/fine/common/rounded_blurred_rect.rs new file mode 100644 index 0000000000..dc84f22a00 --- /dev/null +++ b/vendor/vello_cpu/src/fine/common/rounded_blurred_rect.rs @@ -0,0 +1,249 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Drawing blurred, rounded rectangles. +//! +//! Implementation is adapted from: . + +use crate::fine::{NumericVec, PosExt, ShaderResultF32}; +use crate::kurbo::{Point, Vec2}; +use vello_common::encode::EncodedBlurredRoundedRectangle; +use vello_common::fearless_simd::{Simd, SimdBase, SimdFloat, f32x8, u8x16}; + +#[cfg(not(feature = "std"))] +use vello_common::kurbo::common::FloatFuncs as _; + +#[derive(Debug)] +pub(crate) struct BlurredRoundedRectFiller { + r: f32x8, + g: f32x8, + b: f32x8, + a: f32x8, + alpha_calculator: AlphaCalculator, +} + +impl BlurredRoundedRectFiller { + pub(crate) fn new( + simd: S, + rect: &EncodedBlurredRoundedRectangle, + start_x: f64, + start_y: f64, + ) -> Self { + let start_pos = rect.transform * Point::new(start_x, start_y); + let color_components = rect.color.as_premul_f32().components; + let r = f32x8::splat(simd, color_components[0]); + let g = f32x8::splat(simd, color_components[1]); + let b = f32x8::splat(simd, color_components[2]); + let a = f32x8::splat(simd, color_components[3]); + let simd_rect = SimdRoundedBlurredRect::new(rect, simd); + let alpha_calculator = + AlphaCalculator::new(start_pos, rect.x_advance, rect.y_advance, simd_rect, simd); + + Self { + alpha_calculator, + r, + g, + b, + a, + } + } +} + +impl Iterator for BlurredRoundedRectFiller { + type Item = ShaderResultF32; + + fn next(&mut self) -> Option { + let next = self.alpha_calculator.next().unwrap(); + let r = self.r * next; + let g = self.g * next; + let b = self.b * next; + let a = self.a * next; + + Some(ShaderResultF32 { r, g, b, a }) + } +} + +impl crate::fine::Painter for BlurredRoundedRectFiller { + fn paint_u8(&mut self, buf: &mut [u8]) { + for chunk in buf.chunks_exact_mut(64) { + let first = self.next().unwrap(); + let simd = first.r.simd; + let second = self.next().unwrap(); + + let r = u8x16::from_f32(simd, simd.combine_f32x8(first.r, second.r)); + let g = u8x16::from_f32(simd, simd.combine_f32x8(first.g, second.g)); + let b = u8x16::from_f32(simd, simd.combine_f32x8(first.b, second.b)); + let a = u8x16::from_f32(simd, simd.combine_f32x8(first.a, second.a)); + + let combined = simd.combine_u8x32(simd.combine_u8x16(r, g), simd.combine_u8x16(b, a)); + + simd.store_interleaved_128_u8x64(combined, (&mut chunk[..]).try_into().unwrap()); + } + } + + fn paint_f32(&mut self, buf: &mut [f32]) { + for chunk in buf.chunks_exact_mut(32) { + let (c1, c2) = self.next().unwrap().get(); + c1.simd + .store_interleaved_128_f32x16(c1, (&mut chunk[..16]).try_into().unwrap()); + c2.simd + .store_interleaved_128_f32x16(c2, (&mut chunk[16..]).try_into().unwrap()); + } + } +} + +#[derive(Debug)] +struct AlphaCalculator { + cur_pos: Point, + x_advance: Vec2, + y_advance: Vec2, + r: SimdRoundedBlurredRect, + simd: S, +} + +impl AlphaCalculator { + fn new( + start_pos: Point, + x_advance: Vec2, + y_advance: Vec2, + r: SimdRoundedBlurredRect, + simd: S, + ) -> Self { + Self { + cur_pos: start_pos, + x_advance, + y_advance, + r, + simd, + } + } +} + +impl Iterator for AlphaCalculator { + type Item = f32x8; + + fn next(&mut self) -> Option { + let i = f32x8::splat_pos( + self.simd, + self.cur_pos.x as f32, + self.x_advance.x as f32, + self.y_advance.x as f32, + ); + let j = f32x8::splat_pos( + self.simd, + self.cur_pos.y as f32, + self.x_advance.y as f32, + self.y_advance.y as f32, + ); + let r = &self.r; + + let y = j - r.v1 * r.height; + // Equivalent to r.r1 + y.abs() - (r.h * r.v1) + let y0 = r.r1 - r.h.mul_sub(r.v1, y.abs()); + let y1 = y0.max(r.v0); + + let x = i - r.v1 * r.width; + // Equivalent to r.r1 + x.abs() - (r.w * r.v1) + let x0 = r.r1 - r.w.mul_sub(r.v1, x.abs()); + let x1 = x0.max(r.v0); + let d_pos = (x1.powf(r.exponent) + y1.powf(r.exponent)).powf(r.recip_exponent); + let d_neg = x0.max(y0).min(r.v0); + let d = d_pos + d_neg - r.r1; + let z = r.scale + * (f32x8::compute_erf7(self.simd, r.std_dev_inv * (r.min_edge + d)) + - f32x8::compute_erf7(self.simd, r.std_dev_inv * d)); + + self.cur_pos += 2.0 * self.x_advance; + + Some(z) + } +} + +#[derive(Debug)] +struct SimdRoundedBlurredRect { + pub exponent: f32, + pub recip_exponent: f32, + pub scale: f32x8, + pub std_dev_inv: f32x8, + pub min_edge: f32x8, + pub w: f32x8, + pub h: f32x8, + pub width: f32x8, + pub height: f32x8, + pub r1: f32x8, + pub v0: f32x8, + pub v1: f32x8, +} + +impl SimdRoundedBlurredRect { + fn new(encoded: &EncodedBlurredRoundedRectangle, s: S) -> Self { + let h = f32x8::splat(s, encoded.h); + let w = f32x8::splat(s, encoded.w); + let width = f32x8::splat(s, encoded.width); + let height = f32x8::splat(s, encoded.height); + let r1 = f32x8::splat(s, encoded.r1); + let exponent = encoded.exponent; + let recip_exponent = encoded.recip_exponent; + let scale = f32x8::splat(s, encoded.scale); + let min_edge = f32x8::splat(s, encoded.min_edge); + let std_dev_inv = f32x8::splat(s, encoded.std_dev_inv); + let v0 = f32x8::splat(s, 0.0); + let v1 = f32x8::splat(s, 0.5); + + Self { + exponent, + recip_exponent, + scale, + std_dev_inv, + min_edge, + w, + v0, + v1, + h, + width, + height, + r1, + } + } +} + +trait FloatExt { + // See https://raphlinus.github.io/audio/2018/09/05/sigmoid.html for a little + // explanation of this approximation to the erf function. + // Doing `inline(always)` seems to reduce performance for some reason. + /// Approximate the erf function. + fn compute_erf7(simd: S, x: Self) -> Self; + fn powf(self, x: f32) -> Self; +} + +impl FloatExt for f32x8 { + fn compute_erf7(simd: S, x: Self) -> Self { + // 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.max(Self::splat(simd, -10.0)).min(Self::splat(simd, 10.0)); + let x = x * Self::splat(simd, core::f32::consts::FRAC_2_SQRT_PI); + let xx = x * x; + let p1 = Self::splat(simd, 0.0104).mul_add(xx, Self::splat(simd, 0.03395)); + let p2 = p1.mul_add(xx, Self::splat(simd, 0.24295)); + let p3 = x * xx; + let x = p2.mul_add(p3, x); + let denom = x.mul_add(x, Self::splat(simd, 1.0)).sqrt(); + x / denom + } + + #[inline] + fn powf(mut self, x: f32) -> Self { + // TODO: SIMD + self[0] = self[0].powf(x); + self[1] = self[1].powf(x); + self[2] = self[2].powf(x); + self[3] = self[3].powf(x); + self[4] = self[4].powf(x); + self[5] = self[5].powf(x); + self[6] = self[6].powf(x); + self[7] = self[7].powf(x); + + self + } +} diff --git a/vendor/vello_cpu/src/fine/highp/blend.rs b/vendor/vello_cpu/src/fine/highp/blend.rs new file mode 100644 index 0000000000..b7ea34ce63 --- /dev/null +++ b/vendor/vello_cpu/src/fine/highp/blend.rs @@ -0,0 +1,310 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use crate::peniko::{BlendMode, Mix}; +use crate::util::Premultiply; +use vello_common::fearless_simd::*; + +#[derive(Copy, Clone)] +struct Channels { + r: f32x4, + g: f32x4, + b: f32x4, +} + +impl Channels { + #[inline(always)] + fn unpremultiply(mut self, a: f32x4) -> Self { + self.r = self.r.unpremultiply(a); + self.g = self.g.unpremultiply(a); + self.b = self.b.unpremultiply(a); + + self + } +} + +// TODO: blending is still extremely slow, investigate whether there is something obvious we are +// missing that other renderers do. +pub(crate) fn mix(src_c: f32x16, bg: f32x16, blend_mode: BlendMode) -> f32x16 { + if matches!(blend_mode.mix, Mix::Normal) { + return src_c; + } + // See https://www.w3.org/TR/compositing-1/#blending + let simd = src_c.simd; + + let split = |input: f32x16| { + let mut storage = [0.0; 16]; + simd.store_interleaved_128_f32x16(input, &mut storage); + let input_v = f32x16::from_slice(simd, &storage); + + let p1 = simd.split_f32x16(input_v); + let (r, g) = simd.split_f32x8(p1.0); + let (b, a) = simd.split_f32x8(p1.1); + + (Channels { r, g, b }, a) + }; + + let (bg_channels, bg_a) = split(bg); + let (src_channels, src_a) = split(src_c); + + let unpremultiplied_bg = bg_channels.unpremultiply(bg_a); + let unpremultiplied_src = src_channels.unpremultiply(src_a); + + let mut res_bg = unpremultiplied_bg; + let mix_src = blend_mode.mix(unpremultiplied_src, unpremultiplied_bg); + + let apply_alpha = |unpremultiplied_src_channel: f32x4, + mix_src_channel: f32x4, + dest_channel: &mut f32x4| { + let p1 = (1.0 - bg_a) * unpremultiplied_src_channel; + let p2 = bg_a * mix_src_channel; + + *dest_channel = (p1 + p2).premultiply(src_a); + }; + + apply_alpha(unpremultiplied_src.r, mix_src.r, &mut res_bg.r); + apply_alpha(unpremultiplied_src.g, mix_src.g, &mut res_bg.g); + apply_alpha(unpremultiplied_src.b, mix_src.b, &mut res_bg.b); + + let combined = simd.combine_f32x8( + simd.combine_f32x4(res_bg.r, res_bg.g), + simd.combine_f32x4(res_bg.b, src_a), + ); + + let mut storage = [0.0; 16]; + simd.store_interleaved_128_f32x16(combined, &mut storage); + f32x16::from_slice(simd, &storage) +} + +trait MixExt { + fn mix(&self, src: Channels, bg: Channels) -> Channels; +} + +impl MixExt for BlendMode { + fn mix(&self, src: Channels, bg: Channels) -> Channels { + match self.mix { + Mix::Normal => src, + Mix::Multiply => Multiply::mix(src, bg), + Mix::Screen => Screen::mix(src, bg), + Mix::Overlay => Overlay::mix(src, bg), + Mix::Darken => Darken::mix(src, bg), + Mix::Lighten => Lighten::mix(src, bg), + Mix::ColorDodge => ColorDodge::mix(src, bg), + Mix::ColorBurn => ColorBurn::mix(src, bg), + Mix::HardLight => HardLight::mix(src, bg), + Mix::SoftLight => SoftLight::mix(src, bg), + Mix::Difference => Difference::mix(src, bg), + Mix::Exclusion => Exclusion::mix(src, bg), + Mix::Luminosity => Luminosity::mix(src, bg), + Mix::Color => Color::mix(src, bg), + Mix::Hue => Hue::mix(src, bg), + Mix::Saturation => Saturation::mix(src, bg), + } + } +} + +impl Multiply { + #[inline(always)] + fn single(src: f32x4, bg: f32x4) -> f32x4 { + src * bg + } +} + +impl Screen { + #[inline(always)] + fn single(src: f32x4, bg: f32x4) -> f32x4 { + bg + src - src * bg + } +} + +impl HardLight { + fn single(src: f32x4, bg: f32x4) -> f32x4 { + let two = f32x4::splat(src.simd, 2.0); + + let mask = src.simd.simd_le_f32x4(src, f32x4::splat(src.simd, 0.5)); + let opt1 = Multiply::single(bg, src * two); + let opt2 = Screen::single(bg, two * src - 1.0); + + src.simd.select_f32x4(mask, opt1, opt2) + } +} + +macro_rules! separable_mix { + ($name:ident, $calc:expr) => { + pub(crate) struct $name; + + impl $name { + #[inline(always)] + fn mix(mut src: Channels, bg: Channels) -> Channels { + src.r = $calc(src.r, bg.r); + src.g = $calc(src.g, bg.g); + src.b = $calc(src.b, bg.b); + + src + } + } + }; +} + +separable_mix!(Multiply, |cs: f32x4, cb: f32x4| Multiply::single( + cs, cb +)); +separable_mix!(Screen, |cs: f32x4, cb: f32x4| Screen::single(cs, cb)); +separable_mix!(Overlay, |cs: f32x4, cb: f32x4| HardLight::single( + cb, cs +)); +separable_mix!(Darken, |cs: f32x4, cb: f32x4| cs.min(cb)); +separable_mix!(Lighten, |cs: f32x4, cb: f32x4| cs.max(cb)); +separable_mix!(Difference, |cs: f32x4, cb: f32x4| { + cs.simd + .select_f32x4(cs.simd.simd_le_f32x4(cs, cb), cb - cs, cs - cb) +}); +separable_mix!(HardLight, |cs: f32x4, cb: f32x4| HardLight::single( + cs, cb +)); +separable_mix!(Exclusion, |cs: f32x4, cb: f32x4| { + (cs + cb) - 2.0 * (cs * cb) +}); +separable_mix!(SoftLight, |cs: f32x4, cb: f32x4| { + let mask_1 = cs.simd.simd_le_f32x4(cb, f32x4::splat(cs.simd, 0.25)); + + let d = cs + .simd + .select_f32x4(mask_1, ((16.0 * cb - 12.0) * cb + 4.0) * cb, cb.sqrt()); + + let mask_2 = cs.simd.simd_le_f32x4(cs, f32x4::splat(cs.simd, 0.5)); + + cs.simd.select_f32x4( + mask_2, + cb - (1.0 - 2.0 * cs) * cb * (1.0 - cb), + cb + (2.0 * cs - 1.0) * (d - cb), + ) +}); +separable_mix!(ColorDodge, |cs: f32x4, cb: f32x4| { + let mask_1 = cb.simd.simd_eq_f32x4(cb, f32x4::splat(cb.simd, 0.0)); + let mask_2 = cs.simd.simd_eq_f32x4(cs, f32x4::splat(cs.simd, 1.0)); + + cs.simd.select_f32x4( + // if cb == 0 + mask_1, + f32x4::splat(cs.simd, 0.0), + // else if cs == 1 + cs.simd.select_f32x4( + mask_2, + f32x4::splat(cs.simd, 1.0), + // else + f32x4::splat(cs.simd, 1.0).min(cb / (1.0 - cs)), + ), + ) +}); +separable_mix!(ColorBurn, |cs: f32x4, cb: f32x4| { + let mask_1 = cb.simd.simd_eq_f32x4(cb, f32x4::splat(cb.simd, 1.0)); + let mask_2 = cs.simd.simd_eq_f32x4(cs, f32x4::splat(cs.simd, 0.0)); + + cs.simd.select_f32x4( + // if cb == 1 + mask_1, + f32x4::splat(cs.simd, 1.0), + // else if cs == 0 + cs.simd.select_f32x4( + mask_2, + f32x4::splat(cs.simd, 0.0), + // else + 1.0 - f32x4::splat(cs.simd, 1.0).min((1.0 - cb) / cs), + ), + ) +}); + +macro_rules! non_separable_mix { + ($name:ident, $calc:expr) => { + pub(crate) struct $name; + + impl $name { + #[inline(always)] + fn mix(mut src: Channels, mut bg: Channels) -> Channels { + $calc(&mut src, &mut bg) + } + } + }; +} + +non_separable_mix!(Hue, |cs: &mut Channels, cb: &mut Channels| { + set_sat(&mut cs.r, &mut cs.g, &mut cs.b, sat(cb.r, cb.g, cb.b)); + set_lum(&mut cs.r, &mut cs.g, &mut cs.b, lum(cb.r, cb.g, cb.b)); + + *cs +}); + +non_separable_mix!(Saturation, |cs: &mut Channels, cb: &mut Channels| { + let lum = lum(cb.r, cb.g, cb.b); + set_sat(&mut cb.r, &mut cb.g, &mut cb.b, sat(cs.r, cs.g, cs.b)); + set_lum(&mut cb.r, &mut cb.g, &mut cb.b, lum); + + *cb +}); + +non_separable_mix!(Color, |cs: &mut Channels, cb: &mut Channels| { + set_lum(&mut cs.r, &mut cs.g, &mut cs.b, lum(cb.r, cb.g, cb.b)); + + *cs +}); +non_separable_mix!(Luminosity, |cs: &mut Channels, cb: &mut Channels| { + set_lum(&mut cb.r, &mut cb.g, &mut cb.b, lum(cs.r, cs.g, cs.b)); + + *cb +}); + +fn lum(r: f32x4, g: f32x4, b: f32x4) -> f32x4 { + 0.3 * r + 0.59 * g + 0.11 * b +} + +fn sat(r: f32x4, g: f32x4, b: f32x4) -> f32x4 { + r.max(g).max(b) - r.min(g).min(b) +} + +// PdfCraft patch: backport of linebender/vello#2000. A gray that rounding leaves just below 0 +// or above 1 has `l == n` (or `x == l`), and the unguarded division made a NaN that drew it +// black, whatever the backdrop's alpha; a clip now needs a nonzero denominator. +fn clip_color(r: &mut f32x4, g: &mut f32x4, b: &mut f32x4) { + let simd = r.simd; + let zero = f32x4::splat(simd, 0.0); + + let l = lum(*r, *g, *b); + let n = r.min(g.min(*b)); + let x = r.max(g.max(*b)); + let below = l - n; + let above = x - l; + let clip_low = simd.simd_lt_f32x4(n, zero) & simd.simd_gt_f32x4(below, zero); + let clip_high = + simd.simd_gt_f32x4(x, f32x4::splat(simd, 1.0)) & simd.simd_gt_f32x4(above, zero); + + for c in [r, g, b] { + *c = simd.select_f32x4(clip_low, l + (((*c - l) * l) / below), *c); + *c = simd.select_f32x4(clip_high, l + (((*c - l) * (1.0 - l)) / above), *c); + } +} + +fn set_lum(r: &mut f32x4, g: &mut f32x4, b: &mut f32x4, l: f32x4) { + let d = l - lum(*r, *g, *b); + *r += d; + *g += d; + *b += d; + + clip_color(r, g, b); +} + +// Adapted from tiny-skia +fn set_sat(r: &mut f32x4, g: &mut f32x4, b: &mut f32x4, s: f32x4) { + let simd = r.simd; + let zero = f32x4::splat(simd, 0.0); + let mn = r.min(g.min(*b)); + let mx = r.max(g.max(*b)); + let sat = mx - mn; + + // Map min channel to 0, max channel to s, and scale the middle proportionally. + let scale = |c| simd.select_f32x4(simd.simd_eq_f32x4(sat, zero), zero, (c - mn) * s / sat); + + *r = scale(*r); + *g = scale(*g); + *b = scale(*b); +} diff --git a/vendor/vello_cpu/src/fine/highp/compose.rs b/vendor/vello_cpu/src/fine/highp/compose.rs new file mode 100644 index 0000000000..32983c9abe --- /dev/null +++ b/vendor/vello_cpu/src/fine/highp/compose.rs @@ -0,0 +1,171 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use crate::fine::Splat4thExt; +use crate::peniko::{BlendMode, Compose}; +use vello_common::fearless_simd::*; + +pub(crate) trait ComposeExt { + fn compose( + &self, + simd: S, + src_c: f32x16, + bg_c: f32x16, + alpha_mask: Option>, + ) -> f32x16; +} + +impl ComposeExt for BlendMode { + fn compose( + &self, + simd: S, + src_c: f32x16, + bg_c: f32x16, + alpha_mask: Option>, + ) -> f32x16 { + // There some non-obvious subtleties worth highlighting here. + // We support two kinds of blending (in this case, we focus on compositing specifically): + // - Isolated blending, where layers as a whole are blended together with their backdrop. + // If we are currently performing this kind of blending, `alpha_mask` will always be `None`. + // After all, there is no concrete shape opacity associated with a layer. Instead, we are + // just compositing the RGBA values at _all_ positions of the source layer with the backdrop + // layer. For example, if the backdrop contains a green rectangle and source layer is just + // empty, if we perform blending with `Compose::Clear`, then _everything_ will be cleared, + // because we are compositing the whole source layer with the whole backdrop, and not + // just the parts of the source layer that have actually be drawn on. + // - Non-isolated blending, where a single path is blended with the backdrop. In this case, + // `alpha_mask` _might_ be `Some` and contain the alpha values of the strips we are currently + // compositing. Remember that strips always have a fixed height of 4, because of this, the + // strips might cover areas that aren't actually covered by the path (and just have an alpha + // value of 0, or a value between 0-254 for anti-aliased parts). Because of this, for + // non-isolated blending, we need to lerp the result with the backdrop using `alpha_mask`. + + let mut res = match self.compose { + Compose::SrcOver => SrcOver::compose(simd, src_c, bg_c), + Compose::Clear => Clear::compose(simd, src_c, bg_c), + Compose::Copy => Copy::compose(simd, src_c, bg_c), + Compose::DestOver => DestOver::compose(simd, src_c, bg_c), + Compose::Dest => Dest::compose(simd, src_c, bg_c), + Compose::SrcIn => SrcIn::compose(simd, src_c, bg_c), + Compose::DestIn => DestIn::compose(simd, src_c, bg_c), + Compose::SrcOut => SrcOut::compose(simd, src_c, bg_c), + Compose::DestOut => DestOut::compose(simd, src_c, bg_c), + Compose::SrcAtop => SrcAtop::compose(simd, src_c, bg_c), + Compose::DestAtop => DestAtop::compose(simd, src_c, bg_c), + Compose::Xor => Xor::compose(simd, src_c, bg_c), + Compose::Plus => Plus::compose(simd, src_c, bg_c), + // Have not been able to find a formula for this, so just fallback to Plus. + Compose::PlusLighter => Plus::compose(simd, src_c, bg_c), + }; + + if let Some(alpha_mask) = alpha_mask { + let alpha_mask_inv = 1.0 - alpha_mask; + res = alpha_mask * res + alpha_mask_inv * bg_c; + } + + res + } +} + +macro_rules! compose { + ($name:ident, $fa:expr, $fb:expr, $sat:expr) => { + struct $name; + + impl $name { + fn compose(simd: S, src_c: f32x16, bg_c: f32x16) -> f32x16 { + let al_b = bg_c.splat_4th(); + let al_s = src_c.splat_4th(); + + let fa = $fa(simd, al_s, al_b); + let fb = $fb(simd, al_s, al_b); + + if $sat { + (src_c * fa + fb * bg_c) + .min(f32x16::splat(simd, 1.0)) + .max(f32x16::splat(simd, 0.0)) + } else { + src_c * fa + fb * bg_c + } + } + } + }; +} + +compose!( + Clear, + |simd, _, _| f32x16::splat(simd, 0.0), + |simd, _, _| f32x16::splat(simd, 0.0), + false +); +compose!( + Copy, + |simd, _, _| f32x16::splat(simd, 1.0), + |simd, _, _| f32x16::splat(simd, 0.0), + false +); +compose!( + SrcOver, + |simd, _, _| f32x16::splat(simd, 1.0), + |_, al_s: f32x16, _| 1.0 - al_s, + false +); +compose!( + DestOver, + |_, _, al_b: f32x16| 1.0 - al_b, + |simd, _, _| f32x16::splat(simd, 1.0), + false +); +compose!( + Dest, + |simd, _, _| f32x16::splat(simd, 0.0), + |simd, _, _| f32x16::splat(simd, 1.0), + false +); +compose!( + Xor, + |_, _, al_b: f32x16| 1.0 - al_b, + |_, al_s: f32x16, _| 1.0 - al_s, + false +); +compose!( + SrcIn, + |_, _, al_b: f32x16| al_b, + |simd, _, _| f32x16::splat(simd, 0.0), + false +); +compose!( + DestIn, + |simd, _, _| f32x16::splat(simd, 0.0), + |_, al_s: f32x16, _| al_s, + false +); +compose!( + SrcOut, + |_, _, al_b: f32x16| 1.0 - al_b, + |simd, _, _| f32x16::splat(simd, 0.0), + false +); +compose!( + DestOut, + |simd, _, _| f32x16::splat(simd, 0.0), + |_, al_s: f32x16, _| 1.0 - al_s, + false +); +compose!( + SrcAtop, + |_, _, al_b: f32x16| al_b, + |_, al_s: f32x16, _| 1.0 - al_s, + false +); +compose!( + DestAtop, + |_, _, al_b: f32x16| 1.0 - al_b, + |_, al_s: f32x16, _| al_s, + false +); +compose!( + Plus, + |simd, _, _| f32x16::splat(simd, 1.0), + |simd, _, _| f32x16::splat(simd, 1.0), + true +); diff --git a/vendor/vello_cpu/src/fine/highp/mod.rs b/vendor/vello_cpu/src/fine/highp/mod.rs new file mode 100644 index 0000000000..0948001bee --- /dev/null +++ b/vendor/vello_cpu/src/fine/highp/mod.rs @@ -0,0 +1,544 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! High-precision (f32) rendering kernel implementation. +//! +//! This module implements the fine rasterization stage using 32-bit floating-point +//! values for color components. This provides maximum precision and color accuracy, +//! at the cost of higher memory bandwidth and potentially slower performance compared +//! to the low-precision u8 kernel. +//! +//! The f32 kernel is particularly useful for: +//! - Scenes requiring high precision (e.g., gradients with subtle color transitions) +//! - Debugging and reference implementations +//! - Platforms where SIMD f32 operations are well-optimized + +use crate::filter::filter_highp; +use crate::fine::FineKernel; +use crate::fine::{COLOR_COMPONENTS, Painter, Splat4thExt}; +use crate::layer_manager::LayerManager; +use crate::peniko::BlendMode; +use crate::region::Region; +use vello_common::fearless_simd::*; +use vello_common::filter_effects::Filter; +use vello_common::kurbo::Affine; +use vello_common::mask::Mask; +use vello_common::paint::{PremulColor, Tint, TintMode}; +use vello_common::pixmap::Pixmap; +use vello_common::tile::Tile; + +pub(crate) mod blend; +pub(crate) mod compose; + +/// The kernel for doing rendering using f32. +#[derive(Clone, Copy, Debug)] +pub struct F32Kernel; + +impl FineKernel for F32Kernel { + type Numeric = f32; + type Composite = f32x16; + type NumericVec = f32x16; + + /// Extracts RGBA color components from a premultiplied color as f32 values [0.0, 1.0]. + #[inline(always)] + fn extract_color(color: PremulColor) -> [Self::Numeric; 4] { + color.as_premul_f32().components + } + + /// Copies rendered pixels from the scratch buffer to the output region. + /// + /// Converts f32 color values in [0.0, 1.0] range to u8 [0, 255] with rounding, + /// transforming from column-major scratch buffer to row-major region layout. + #[inline(always)] + fn pack(simd: S, region: &mut Region<'_>, blend_buf: &[Self::Numeric]) { + simd.vectorize( + #[inline(always)] + || { + for y in 0..Tile::HEIGHT { + for (x, pixel) in region + .row_mut(y) + .chunks_exact_mut(COLOR_COMPONENTS) + .enumerate() + { + let idx = + COLOR_COMPONENTS * (usize::from(Tile::HEIGHT) * x + usize::from(y)); + let start = &blend_buf[idx..]; + // Convert f32 [0.0, 1.0] to u8 [0, 255] with rounding. + // TODO: Use explicit SIMD for better performance + let converted = [ + (start[0] * 255.0 + 0.5) as u8, + (start[1] * 255.0 + 0.5) as u8, + (start[2] * 255.0 + 0.5) as u8, + (start[3] * 255.0 + 0.5) as u8, + ]; + pixel.copy_from_slice(&converted); + } + } + }, + ); + } + + /// Copies pixels from the output region to the scratch buffer. + /// + /// Converts u8 color values [0, 255] to normalized f32 [0.0, 1.0], + /// transforming from row-major region layout to column-major scratch buffer. + /// This is the inverse operation of `pack`. + #[inline(always)] + fn unpack(simd: S, region: &mut Region<'_>, blend_buf: &mut [Self::Numeric]) { + simd.vectorize( + #[inline(always)] + || { + for y in 0..Tile::HEIGHT { + for (x, pixel) in region.row_mut(y).chunks_exact(COLOR_COMPONENTS).enumerate() { + let idx = + COLOR_COMPONENTS * (usize::from(Tile::HEIGHT) * x + usize::from(y)); + let start = &mut blend_buf[idx..]; + // Convert u8 [0, 255] to normalized f32 [0.0, 1.0] (reverse of pack) + start[0] = pixel[0] as f32 / 255.0; + start[1] = pixel[1] as f32 / 255.0; + start[2] = pixel[2] as f32 / 255.0; + start[3] = pixel[3] as f32 / 255.0; + } + } + }, + ); + } + + /// Applies a filter effect to a rendered layer. + /// + /// Delegates to the f32-specific filter implementation. + fn filter_layer( + pixmap: &mut Pixmap, + filter: &Filter, + layer_manager: &mut LayerManager, + transform: Affine, + ) { + filter_highp(filter, pixmap, layer_manager, transform); + } + + /// Fills a buffer with a solid color using SIMD operations. + /// + /// Efficiently broadcasts a single RGBA color across all pixels in the destination. + #[inline(never)] + fn copy_solid(simd: S, dest: &mut [Self::Numeric], src: [Self::Numeric; 4]) { + simd.vectorize( + #[inline(always)] + || { + let color = f32x16::block_splat(src.simd_into(simd)); + + for el in dest.chunks_exact_mut(16) { + color.store_slice(el); + } + }, + ); + } + + /// Applies per-pixel mask values to a buffer by multiplying each component. + /// + /// Used for anti-aliasing and clipping effects. Each pixel is multiplied by + /// its corresponding mask value (already normalized to [0.0, 1.0]). + fn apply_mask( + simd: S, + dest: &mut [Self::Numeric], + mut src: impl Iterator, + ) { + simd.vectorize( + #[inline(always)] + || { + for el in dest.chunks_exact_mut(16) { + let loaded = f32x16::from_slice(simd, el); + let mulled = loaded * src.next().unwrap(); + mulled.store_slice(el); + } + }, + ); + } + + /// Applies a painter's output to the destination buffer. + /// + /// Delegates to the painter's f32-specific implementation. + #[inline(always)] + fn apply_painter<'a>(_: S, dest: &mut [Self::Numeric], mut painter: impl Painter + 'a) { + painter.paint_f32(dest); + } + + #[inline(always)] + fn apply_tint(simd: S, dest: &mut [Self::Numeric], tint: &Tint) { + let premul = tint.color.premultiply(); + let [r, g, b, a] = premul.components; + let tint_v = f32x16::block_splat(f32x4::from_slice(simd, &[r, g, b, a])); + + simd.vectorize( + #[inline(always)] + || match tint.mode { + TintMode::AlphaMask => { + for chunk in dest.chunks_exact_mut(16) { + let pixel = f32x16::from_slice(simd, chunk); + let alphas = pixel.splat_4th(); + let tinted = tint_v * alphas; + tinted.store_slice(chunk); + } + } + TintMode::Multiply => { + for chunk in dest.chunks_exact_mut(16) { + let pixel = f32x16::from_slice(simd, chunk); + let tinted = pixel * tint_v; + tinted.store_slice(chunk); + } + } + }, + ); + } + + /// Composites a solid color onto a buffer using alpha blending. + /// + /// Dispatches to either the masked or unmasked implementation based on the + /// presence of per-pixel alpha masks. + #[inline(always)] + fn alpha_composite_solid( + simd: S, + dest: &mut [Self::Numeric], + src: [Self::Numeric; 4], + alphas: Option<&[u8]>, + ) { + if let Some(alphas) = alphas { + alpha_fill::alpha_composite_solid( + simd, + dest, + src, + bytemuck::cast_slice::(alphas).iter().copied(), + ); + } else { + fill::alpha_composite_solid(simd, dest, src); + } + } + + /// Composites a source buffer onto a destination buffer using alpha blending. + /// + /// Dispatches to either the masked or unmasked implementation based on the + /// presence of per-pixel alpha masks. Each source pixel's alpha determines + /// the blending amount. + fn alpha_composite_buffer( + simd: S, + dest: &mut [Self::Numeric], + src: &[Self::Numeric], + alphas: Option<&[u8]>, + ) { + if let Some(alphas) = alphas { + alpha_fill::alpha_composite_arbitrary( + simd, + dest, + src.chunks_exact(16).map(|el| f32x16::from_slice(simd, el)), + bytemuck::cast_slice::(alphas).iter().copied(), + ); + } else { + fill::alpha_composite_arbitrary( + simd, + dest, + src.chunks_exact(16).map(|el| f32x16::from_slice(simd, el)), + ); + } + } + + /// Applies a blend mode to composite source pixels onto destination. + /// + /// Dispatches to either the masked or unmasked blend implementation. + /// Handles both color mixing (multiply, screen, etc.) and compositing. + fn blend( + simd: S, + dest: &mut [Self::Numeric], + mut start_x: u16, + start_y: u16, + src: impl Iterator, + blend_mode: BlendMode, + alphas: Option<&[u8]>, + mask: Option<&Mask>, + ) { + let alpha_iter = alphas.map(|a| bytemuck::cast_slice::(a).iter().copied()); + + let mask_iter = mask.map(|m| { + let width = m.width(); + let height = m.height(); + + core::iter::from_fn(move || { + let samples = if start_x < width && start_y + 3 < height { + // All in bounds, sample directly + [ + m.sample(start_x, start_y), + m.sample(start_x, start_y + 1), + m.sample(start_x, start_y + 2), + m.sample(start_x, start_y + 3), + ] + } else { + // Fallback: check each individually + [ + if start_x < width && start_y < height { + m.sample(start_x, start_y) + } else { + 255 + }, + if start_x < width && start_y + 1 < height { + m.sample(start_x, start_y + 1) + } else { + 255 + }, + if start_x < width && start_y + 2 < height { + m.sample(start_x, start_y + 2) + } else { + 255 + }, + if start_x < width && start_y + 3 < height { + m.sample(start_x, start_y + 3) + } else { + 255 + }, + ] + }; + + start_x += 1; + + Some(samples) + }) + }); + + match (alpha_iter, mask_iter) { + (Some(alpha_iter), Some(mut mask_iter)) => { + let iter = alpha_iter.map(|a1| { + let a2 = mask_iter.next().unwrap(); + [ + ((a1[0] as u16 * a2[0] as u16) / 255) as u8, + ((a1[1] as u16 * a2[1] as u16) / 255) as u8, + ((a1[2] as u16 * a2[2] as u16) / 255) as u8, + ((a1[3] as u16 * a2[3] as u16) / 255) as u8, + ] + }); + alpha_fill::blend(simd, dest, src, iter, blend_mode); + } + (None, Some(mask_iter)) => alpha_fill::blend(simd, dest, src, mask_iter, blend_mode), + (Some(alpha_iter), None) => alpha_fill::blend(simd, dest, src, alpha_iter, blend_mode), + (None, None) => { + fill::blend(simd, dest, src, blend_mode); + } + } + } +} + +mod fill { + //! Alpha compositing and blending operations without per-pixel alpha masks. + //! + //! This module handles the case where we're compositing full opacity pixels, + //! using only the source alpha channel for compositing. + + use crate::fine::Splat4thExt; + use crate::fine::highp::blend; + use crate::fine::highp::compose::ComposeExt; + use crate::peniko::BlendMode; + + use vello_common::fearless_simd::*; + + // IMPORTANT: The inlining attributes (#[inline(always)], #[inline(never)]) in this + // module have been carefully tuned through benchmarking. Changing them can cause + // significant performance regressions. + + /// Composites a solid color onto a buffer using alpha blending. + /// + /// Uses the "over" operator: `result = src + bg * (1 - src_alpha)` + #[inline(always)] + pub(super) fn alpha_composite_solid(s: S, dest: &mut [f32], src: [f32; 4]) { + s.vectorize( + #[inline(always)] + || { + let one_minus_alpha = 1.0 - f32x16::block_splat(f32x4::splat(s, src[3])); + let src_c = f32x16::block_splat(f32x4::simd_from(s, src)); + + for next_dest in dest.chunks_exact_mut(16) { + alpha_composite_inner(s, next_dest, src_c, one_minus_alpha); + } + }, + ); + } + + /// Composites a buffer of colors onto another buffer using alpha blending. + /// + /// Each source pixel is composited individually based on its alpha channel. + #[inline(always)] + pub(super) fn alpha_composite_arbitrary>>( + simd: S, + dest: &mut [f32], + src: T, + ) { + simd.vectorize( + #[inline(always)] + || { + for (next_dest, next_src) in dest.chunks_exact_mut(16).zip(src) { + let one_minus_alpha = 1.0 - next_src.splat_4th(); + alpha_composite_inner(simd, next_dest, next_src, one_minus_alpha); + } + }, + ); + } + + /// Applies blend mode compositing to a buffer without per-pixel masks. + pub(super) fn blend>>( + simd: S, + dest: &mut [f32], + src: T, + blend_mode: BlendMode, + ) { + for (next_dest, next_src) in dest.chunks_exact_mut(16).zip(src) { + let bg_v = f32x16::from_slice(simd, next_dest); + let src_c = blend::mix(next_src, bg_v, blend_mode); + let res = blend_mode.compose(simd, src_c, bg_v, None); + res.store_slice(next_dest); + } + } + + /// Performs the core alpha compositing calculation. + /// + /// Formula: `result = src + bg * (1 - src_alpha)` + /// This implements the Porter-Duff "source over" operator using FMA for efficiency. + #[inline(always)] + fn alpha_composite_inner( + s: S, + dest: &mut [f32], + src: f32x16, + one_minus_alpha: f32x16, + ) { + let mut bg_c = f32x16::from_slice(s, dest); + bg_c = one_minus_alpha.mul_add(bg_c, src); + bg_c.store_slice(dest); + } +} + +mod alpha_fill { + //! Alpha compositing and blending operations with per-pixel alpha masks. + //! + //! This module handles compositing when each pixel has an additional mask value + //! (e.g., from anti-aliasing or clip masks) that modulates the source alpha. + + use crate::fine::Splat4thExt; + use crate::fine::highp::compose::ComposeExt; + use crate::fine::highp::{blend, extract_masks}; + use crate::peniko::BlendMode; + use vello_common::fearless_simd::*; + + /// Composites a solid color with per-pixel alpha masks. + /// + /// Combines source alpha with mask values: `effective_alpha = src_alpha * mask` + #[inline(always)] + pub(super) fn alpha_composite_solid( + s: S, + dest: &mut [f32], + src: [f32; 4], + alphas: impl Iterator, + ) { + s.vectorize( + #[inline(always)] + || { + let src_a = f32x16::splat(s, src[3]); + let src_c = f32x16::block_splat(src.simd_into(s)); + let one = f32x16::splat(s, 1.0); + + for (next_dest, next_mask) in dest.chunks_exact_mut(16).zip(alphas) { + alpha_composite_inner(s, next_dest, &next_mask, src_c, src_a, one); + } + }, + ); + } + + /// Composites a buffer of colors with per-pixel alpha masks. + /// + /// Each pixel's source alpha is modulated by its corresponding mask value. + pub(super) fn alpha_composite_arbitrary>>( + simd: S, + dest: &mut [f32], + src: T, + alphas: impl Iterator, + ) { + simd.vectorize( + #[inline(always)] + || { + let one = f32x16::splat(simd, 1.0); + + for ((next_dest, next_mask), next_src) in + dest.chunks_exact_mut(16).zip(alphas).zip(src) + { + let src_a = next_src.splat_4th(); + alpha_composite_inner(simd, next_dest, &next_mask, next_src, src_a, one); + } + }, + ); + } + + /// Applies blend mode compositing with per-pixel alpha masks. + pub(super) fn blend>>( + simd: S, + dest: &mut [f32], + src: T, + alphas: impl Iterator, + blend_mode: BlendMode, + ) { + simd.vectorize( + #[inline(always)] + || { + for ((next_dest, next_mask), next_src) in + dest.chunks_exact_mut(16).zip(alphas).zip(src) + { + let masks = extract_masks(simd, &next_mask); + let bg = f32x16::from_slice(simd, next_dest); + let src_c = blend::mix(next_src, bg, blend_mode); + let res = blend_mode.compose(simd, src_c, bg, Some(masks)); + res.store_slice(next_dest); + } + }, + ); + } + + /// Performs alpha compositing with mask modulation. + /// + /// Formula: `result = src * mask + bg * (1 - src_alpha * mask)` + /// The mask value modulates both the source contribution and the inverse alpha. + /// Uses FMA instructions for optimal performance. + #[inline(always)] + fn alpha_composite_inner( + s: S, + dest: &mut [f32], + masks: &[u8; 4], + src_c: f32x16, + src_a: f32x16, + one: f32x16, + ) { + let bg_c = f32x16::from_slice(s, dest); + let mask_a = extract_masks(s, masks); + // 1 - src_a * mask_a + let inv_src_a_mask_a = src_a.mul_add(-mask_a, one); + + let res = bg_c.mul_add(inv_src_a_mask_a, src_c * mask_a); + res.store_slice(dest); + } +} + +/// Expands 4 mask bytes into a 16-element f32 SIMD vector with normalized values. +/// +/// Converts u8 mask values to f32 in range [0.0, 1.0], then duplicates each mask +/// value across 4 consecutive elements (one per color component). +/// +/// Input: [m0, m1, m2, m3] (as u8, 0-255) +/// Output: [m0/255, m0/255, m0/255, m0/255, m1/255, ..., m3/255] (as f32, 16 elements) +#[inline(always)] +fn extract_masks(simd: S, masks: &[u8; 4]) -> f32x16 { + let mut base_mask = [ + masks[0] as f32, + masks[1] as f32, + masks[2] as f32, + masks[3] as f32, + ] + .simd_into(simd); + + base_mask *= f32x4::splat(simd, 1.0 / 255.0); + + let res = f32x16::block_splat(base_mask); + let zip_low = res.zip_low(res); + + zip_low.zip_low(zip_low) +} diff --git a/vendor/vello_cpu/src/fine/lowp/compose.rs b/vendor/vello_cpu/src/fine/lowp/compose.rs new file mode 100644 index 0000000000..c03d43bd4a --- /dev/null +++ b/vendor/vello_cpu/src/fine/lowp/compose.rs @@ -0,0 +1,161 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use crate::fine::Splat4thExt; +use crate::peniko::{BlendMode, Compose}; +use crate::util::NormalizedMulExt; +use vello_common::fearless_simd::*; +use vello_common::util::Div255Ext; + +pub(crate) trait ComposeExt { + fn compose( + &self, + simd: S, + src_c: u8x32, + bg_c: u8x32, + alpha_mask: Option>, + ) -> u8x32; +} + +impl ComposeExt for BlendMode { + fn compose( + &self, + simd: S, + src_c: u8x32, + bg_c: u8x32, + alpha_mask: Option>, + ) -> u8x32 { + let mut res = match self.compose { + Compose::SrcOver => SrcOver::compose(simd, src_c, bg_c), + Compose::Clear => Clear::compose(simd, src_c, bg_c), + Compose::Copy => Copy::compose(simd, src_c, bg_c), + Compose::DestOver => DestOver::compose(simd, src_c, bg_c), + Compose::Dest => Dest::compose(simd, src_c, bg_c), + Compose::SrcIn => SrcIn::compose(simd, src_c, bg_c), + Compose::DestIn => DestIn::compose(simd, src_c, bg_c), + Compose::SrcOut => SrcOut::compose(simd, src_c, bg_c), + Compose::DestOut => DestOut::compose(simd, src_c, bg_c), + Compose::SrcAtop => SrcAtop::compose(simd, src_c, bg_c), + Compose::DestAtop => DestAtop::compose(simd, src_c, bg_c), + Compose::Xor => Xor::compose(simd, src_c, bg_c), + Compose::Plus => Plus::compose(simd, src_c, bg_c), + // Have not been able to find a formula for this, so just fallback to Plus. + Compose::PlusLighter => Plus::compose(simd, src_c, bg_c), + }; + + if let Some(alpha_mask) = alpha_mask { + let alpha_mask_inv = 255 - alpha_mask; + let p1 = simd.widen_u8x32(alpha_mask) * simd.widen_u8x32(res); + let p2 = simd.widen_u8x32(alpha_mask_inv) * simd.widen_u8x32(bg_c); + res = simd.narrow_u16x32((p1 + p2).div_255()); + } + + res + } +} + +macro_rules! compose { + ($name:ident, $fa:expr, $fb:expr, $sat:expr) => { + struct $name; + + impl $name { + fn compose(simd: S, src_c: u8x32, bg_c: u8x32) -> u8x32 { + let al_b = bg_c.splat_4th(); + let al_s = src_c.splat_4th(); + + let fa = $fa(simd, al_s, al_b); + let fb = $fb(simd, al_s, al_b); + + if $sat { + simd.narrow_u16x32( + (simd.widen_u8x32(src_c.normalized_mul(fa)) + + simd.widen_u8x32(fb.normalized_mul(bg_c))) + .min(u16x32::splat(simd, 255)) + .max(u16x32::splat(simd, 0)), + ) + } else { + src_c.normalized_mul(fa) + fb.normalized_mul(bg_c) + } + } + } + }; +} + +compose!( + Clear, + |simd, _, _| u8x32::splat(simd, 0), + |simd, _, _| u8x32::splat(simd, 0), + false +); +compose!( + Copy, + |simd, _, _| u8x32::splat(simd, 255), + |simd, _, _| u8x32::splat(simd, 0), + false +); +compose!( + SrcOver, + |simd, _, _| u8x32::splat(simd, 255), + |_, al_s: u8x32, _| 255 - al_s, + false +); +compose!( + DestOver, + |_, _, al_b: u8x32| 255 - al_b, + |simd, _, _| u8x32::splat(simd, 255), + false +); +compose!( + Dest, + |simd, _, _| u8x32::splat(simd, 0), + |simd, _, _| u8x32::splat(simd, 255), + false +); +compose!( + Xor, + |_, _, al_b: u8x32| 255 - al_b, + |_, al_s: u8x32, _| 255 - al_s, + false +); +compose!( + SrcIn, + |_, _, al_b: u8x32| al_b, + |simd, _, _| u8x32::splat(simd, 0), + false +); +compose!( + DestIn, + |simd, _, _| u8x32::splat(simd, 0), + |_, al_s: u8x32, _| al_s, + false +); +compose!( + SrcOut, + |_, _, al_b: u8x32| 255 - al_b, + |simd, _, _| u8x32::splat(simd, 0), + false +); +compose!( + DestOut, + |simd, _, _| u8x32::splat(simd, 0), + |_, al_s: u8x32, _| 255 - al_s, + false +); +compose!( + SrcAtop, + |_, _, al_b: u8x32| al_b, + |_, al_s: u8x32, _| 255 - al_s, + false +); +compose!( + DestAtop, + |_, _, al_b: u8x32| 255 - al_b, + |_, al_s: u8x32, _| al_s, + false +); +compose!( + Plus, + |simd, _, _| u8x32::splat(simd, 255), + |simd, _, _| u8x32::splat(simd, 255), + true +); diff --git a/vendor/vello_cpu/src/fine/lowp/gradient.rs b/vendor/vello_cpu/src/fine/lowp/gradient.rs new file mode 100644 index 0000000000..210818a524 --- /dev/null +++ b/vendor/vello_cpu/src/fine/lowp/gradient.rs @@ -0,0 +1,84 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use crate::peniko; +use core::slice::ChunksExact; +use vello_common::encode::EncodedGradient; +use vello_common::fearless_simd::*; + +/// An accelerated gradient painter for u8. +/// +/// Assumes that the gradient has no undefined positions. +#[derive(Debug)] +pub(crate) struct GradientPainter<'a, S: Simd> { + gradient: &'a EncodedGradient, + lut: &'a [[u8; 4]], + t_vals: ChunksExact<'a, f32>, + scale_factor: f32x16, + simd: S, +} + +impl<'a, S: Simd> GradientPainter<'a, S> { + pub(crate) fn new(simd: S, gradient: &'a EncodedGradient, t_vals: &'a [f32]) -> Self { + let lut = gradient.u8_lut(simd); + let scale_factor = f32x16::splat(simd, lut.scale_factor()); + + Self { + gradient, + scale_factor, + lut: lut.lut(), + t_vals: t_vals.chunks_exact(16), + simd, + } + } +} + +impl Iterator for GradientPainter<'_, S> { + type Item = u8x64; + + #[inline(always)] + fn next(&mut self) -> Option { + let extend = self.gradient.extend; + let pos = f32x16::from_slice(self.simd, self.t_vals.next()?); + let t_vals = apply_extend(pos, extend); + let indices = (t_vals * self.scale_factor).to_int::>(); + + let mut vals = [0_u8; 64]; + for (val, idx) in vals.chunks_exact_mut(4).zip(*indices) { + val.copy_from_slice(&self.lut[idx as usize]); + } + + Some(u8x64::from_slice(self.simd, &vals)) + } +} + +impl crate::fine::Painter for GradientPainter<'_, S> { + fn paint_u8(&mut self, buf: &mut [u8]) { + self.simd.vectorize( + #[inline(always)] + || { + for chunk in buf.chunks_exact_mut(64) { + self.next().unwrap().store_slice(chunk); + } + }, + ); + } + + fn paint_f32(&mut self, _: &mut [f32]) { + unimplemented!() + } +} + +// TODO: Maybe delete this method and use `apply_extend` from highp by splitting into two f32x8. +#[inline(always)] +pub(crate) fn apply_extend(val: f32x16, extend: peniko::Extend) -> f32x16 { + match extend { + peniko::Extend::Pad => val.max(0.0).min(1.0), + peniko::Extend::Repeat => (val - val.floor()).fract(), + // See + peniko::Extend::Reflect => ((val - 1.0) - 2.0 * ((val - 1.0) * 0.5).floor() - 1.0) + .abs() + .max(0.0) + .min(1.0), + } +} diff --git a/vendor/vello_cpu/src/fine/lowp/image.rs b/vendor/vello_cpu/src/fine/lowp/image.rs new file mode 100644 index 0000000000..f68e2162cf --- /dev/null +++ b/vendor/vello_cpu/src/fine/lowp/image.rs @@ -0,0 +1,259 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use crate::fine::PosExt; +use crate::fine::common::image::{ImagePainterData, extend, fract_floor, sample}; +use crate::fine::macros::u8x16_painter; +use vello_common::encode::EncodedImage; +use vello_common::fearless_simd::{Simd, SimdBase, SimdFloat, f32x4, u8x16, u16x16}; +use vello_common::pixmap::Pixmap; +use vello_common::simd::element_wise_splat; +use vello_common::util::{Div255Ext, f32_to_u8}; + +/// A faster bilinear image renderer for the u8 pipeline. +#[derive(Debug)] +pub(crate) struct BilinearImagePainter<'a, S: Simd> { + data: ImagePainterData<'a, S>, + simd: S, +} + +impl<'a, S: Simd> BilinearImagePainter<'a, S> { + pub(crate) fn new( + simd: S, + image: &'a EncodedImage, + pixmap: &'a Pixmap, + start_x: f64, + start_y: f64, + ) -> Self { + let data = ImagePainterData::new(simd, image, pixmap, start_x, start_y); + + Self { data, simd } + } +} + +impl Iterator for BilinearImagePainter<'_, S> { + type Item = u8x16; + + fn next(&mut self) -> Option { + let x_positions = f32x4::splat_pos( + self.simd, + self.data.cur_pos.x as f32, + self.data.x_advances.0, + self.data.y_advances.0, + ); + + let y_positions = f32x4::splat_pos( + self.simd, + self.data.cur_pos.y as f32, + self.data.x_advances.1, + self.data.y_advances.1, + ); + + let extend_x = |x_pos: f32x4| { + extend( + self.simd, + x_pos, + self.data.image.sampler.x_extend, + self.data.width, + self.data.width_inv, + ) + }; + + let extend_y = |y_pos: f32x4| { + extend( + self.simd, + y_pos, + self.data.image.sampler.y_extend, + self.data.height, + self.data.height_inv, + ) + }; + + let fx = f32_to_u8(element_wise_splat( + self.simd, + fract_floor(x_positions + 0.5).mul_add(255.0, 0.5), + )); + let fy = f32_to_u8(element_wise_splat( + self.simd, + fract_floor(y_positions + 0.5).mul_add(255.0, 0.5), + )); + + let fx = self.simd.widen_u8x16(fx); + let fy = self.simd.widen_u8x16(fy); + let fx_inv = u16x16::splat(self.simd, 255) - fx; + let fy_inv = u16x16::splat(self.simd, 255) - fy; + + let x_pos1 = extend_x(x_positions - 0.5); + let x_pos2 = extend_x(x_positions + 0.5); + let y_pos1 = extend_y(y_positions - 0.5); + let y_pos2 = extend_y(y_positions + 0.5); + + let p00 = self + .simd + .widen_u8x16(sample(self.simd, &self.data, x_pos1, y_pos1)); + let p10 = self + .simd + .widen_u8x16(sample(self.simd, &self.data, x_pos2, y_pos1)); + let p01 = self + .simd + .widen_u8x16(sample(self.simd, &self.data, x_pos1, y_pos2)); + let p11 = self + .simd + .widen_u8x16(sample(self.simd, &self.data, x_pos2, y_pos2)); + + let ip1 = (p00 * fx_inv + p10 * fx).div_255(); + let ip2 = (p01 * fx_inv + p11 * fx).div_255(); + let res = self.simd.narrow_u16x16((ip1 * fy_inv + ip2 * fy).div_255()); + + self.data.cur_pos += self.data.image.x_advance; + + Some(res) + } +} + +u8x16_painter!(BilinearImagePainter<'_, S>); + +/// A faster bilinear image renderer for axis-aligned images (no skew) in the u8 pipeline. +/// +/// This is an optimized version of `BilinearImagePainter` that pre-computes y positions +/// and interpolation weights since they don't change when there's no skew. +#[derive(Debug)] +pub(crate) struct PlainBilinearImagePainter<'a, S: Simd> { + data: ImagePainterData<'a, S>, + simd: S, + /// Pre-computed y sample positions (top row for bilinear grid) + y_pos1: f32x4, + /// Pre-computed y sample positions (bottom row for bilinear grid) + y_pos2: f32x4, + /// Pre-computed y interpolation weight + fy: u16x16, + /// Pre-computed inverse y interpolation weight + fy_inv: u16x16, + /// Current x position + cur_x_pos: f32x4, + /// X advance per iteration + advance: f32, +} + +impl<'a, S: Simd> PlainBilinearImagePainter<'a, S> { + pub(crate) fn new( + simd: S, + image: &'a EncodedImage, + pixmap: &'a Pixmap, + start_x: f64, + start_y: f64, + ) -> Self { + let data = ImagePainterData::new(simd, image, pixmap, start_x, start_y); + + // For axis-aligned images, y doesn't change across the strip + let y_positions = f32x4::splat_pos( + simd, + data.cur_pos.y as f32, + data.x_advances.1, + data.y_advances.1, + ); + + // Pre-compute y extend positions + let y_pos1 = extend( + simd, + y_positions - 0.5, + image.sampler.y_extend, + data.height, + data.height_inv, + ); + let y_pos2 = extend( + simd, + y_positions + 0.5, + image.sampler.y_extend, + data.height, + data.height_inv, + ); + + // Pre-compute y interpolation weights + let fy = f32_to_u8(element_wise_splat( + simd, + fract_floor(y_positions + 0.5).mul_add(255.0, 0.5), + )); + let fy = simd.widen_u8x16(fy); + let fy_inv = u16x16::splat(simd, 255) - fy; + + let cur_x_pos = f32x4::splat_pos( + simd, + data.cur_pos.x as f32, + data.x_advances.0, + data.y_advances.0, + ); + + Self { + data, + y_pos1, + y_pos2, + fy, + fy_inv, + cur_x_pos, + advance: image.x_advance.x as f32, + simd, + } + } +} + +impl Iterator for PlainBilinearImagePainter<'_, S> { + type Item = u8x16; + + #[inline(always)] + fn next(&mut self) -> Option { + let x_minus_half = self.cur_x_pos - 0.5; + let x_plus_half = self.cur_x_pos + 0.5; + + // Only x needs to be extended per-iteration + let x_pos1 = extend( + self.simd, + x_minus_half, + self.data.image.sampler.x_extend, + self.data.width, + self.data.width_inv, + ); + let x_pos2 = extend( + self.simd, + x_plus_half, + self.data.image.sampler.x_extend, + self.data.width, + self.data.width_inv, + ); + + // Compute x interpolation weights + let fx = f32_to_u8(element_wise_splat( + self.simd, + fract_floor(x_plus_half).mul_add(255.0, 0.5), + )); + let fx = self.simd.widen_u8x16(fx); + let fx_inv = u16x16::splat(self.simd, 255) - fx; + + // Sample the 4 corners using pre-computed y positions + let p00 = self + .simd + .widen_u8x16(sample(self.simd, &self.data, x_pos1, self.y_pos1)); + let p10 = self + .simd + .widen_u8x16(sample(self.simd, &self.data, x_pos2, self.y_pos1)); + let p01 = self + .simd + .widen_u8x16(sample(self.simd, &self.data, x_pos1, self.y_pos2)); + let p11 = self + .simd + .widen_u8x16(sample(self.simd, &self.data, x_pos2, self.y_pos2)); + + // Bilinear interpolation + let ip1 = (p00 * fx_inv + p10 * fx).div_255(); + let ip2 = (p01 * fx_inv + p11 * fx).div_255(); + let res = self + .simd + .narrow_u16x16((ip1 * self.fy_inv + ip2 * self.fy).div_255()); + + self.cur_x_pos += self.advance; + + Some(res) + } +} + +u8x16_painter!(PlainBilinearImagePainter<'_, S>); diff --git a/vendor/vello_cpu/src/fine/lowp/mod.rs b/vendor/vello_cpu/src/fine/lowp/mod.rs new file mode 100644 index 0000000000..c59d68122d --- /dev/null +++ b/vendor/vello_cpu/src/fine/lowp/mod.rs @@ -0,0 +1,770 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Low-precision (u8/u16) rendering kernel implementation. +//! +//! This module implements the fine rasterization stage using 8-bit unsigned integers +//! for color values and 16-bit for intermediate calculations. This provides better +//! performance on many architectures compared to floating-point operations, while +//! maintaining sufficient precision for most rendering tasks. + +mod compose; +mod gradient; +mod image; + +use crate::filter::filter_lowp; +use crate::fine::lowp::image::{BilinearImagePainter, PlainBilinearImagePainter}; +use crate::fine::{COLOR_COMPONENTS, Painter, SCRATCH_BUF_SIZE, Splat4thExt}; +use crate::fine::{FineKernel, highp, u8_to_f32}; +use crate::layer_manager::LayerManager; +use crate::peniko::BlendMode; +use crate::region::Region; +use crate::util::NormalizedMulExt; +use crate::util::scalar::div_255; +use bytemuck::cast_slice; +use core::iter; +use vello_common::coarse::WideTile; +use vello_common::encode::{EncodedGradient, EncodedImage}; +use vello_common::fearless_simd::*; +use vello_common::filter_effects::Filter; +use vello_common::kurbo::Affine; +use vello_common::mask::Mask; +use vello_common::paint::{PremulColor, Tint, TintMode}; +use vello_common::pixmap::Pixmap; +use vello_common::tile::Tile; +use vello_common::util::{Div255Ext, f32_to_u8}; + +/// The kernel for doing rendering using u8/u16. +#[derive(Clone, Copy, Debug)] +pub struct U8Kernel; + +impl FineKernel for U8Kernel { + type Numeric = u8; + type Composite = u8x32; + type NumericVec = u8x16; + + /// Extracts RGBA color components from a premultiplied color as u8 values [0, 255]. + #[inline] + fn extract_color(color: PremulColor) -> [Self::Numeric; 4] { + color.as_premul_rgba8().to_u8_array() + } + + /// Copies rendered pixels from the scratch buffer to the output region. + /// + /// Converts from column-major scratch buffer layout to row-major region layout, + /// using either a SIMD-optimized path for full tiles or a scalar fallback. + #[inline(always)] + fn pack(simd: S, region: &mut Region<'_>, blend_buf: &[Self::Numeric]) { + if region.width != WideTile::WIDTH || region.height != Tile::HEIGHT { + // Use scalar path for non-standard tile sizes. Wrapping this in `vectorize` + // degrades performance significantly on SSE4.2. + pack(region, blend_buf); + } else { + simd.vectorize( + #[inline(always)] + || { + pack_block(simd, region, blend_buf); + }, + ); + } + } + + /// Copies pixels from the output region to the scratch buffer. + /// + /// Converts from row-major region layout to column-major scratch buffer layout. + /// This is the inverse operation of `pack`. + #[inline(always)] + fn unpack(simd: S, region: &mut Region<'_>, blend_buf: &mut [Self::Numeric]) { + if region.width != WideTile::WIDTH || region.height != Tile::HEIGHT { + // Use scalar path for non-standard tile sizes. + // Note that right now, this path is unused (only for benchmarking), because when + // using filters we always allocate pixmaps of the same size as a wide tile. + // Nevertheless, we still keep this function here for reference, or in case that + // changes in the future. + unpack(region, blend_buf); + } else { + simd.vectorize( + #[inline(always)] + || { + unpack_block(simd, region, blend_buf); + }, + ); + } + } + + /// Applies a filter effect to a rendered layer. + /// + /// Delegates to the u8-specific filter implementation. + fn filter_layer( + pixmap: &mut Pixmap, + filter: &Filter, + layer_manager: &mut LayerManager, + transform: Affine, + ) { + filter_lowp(filter, pixmap, layer_manager, transform); + } + + /// Fills a buffer with a solid color using SIMD operations. + /// + /// Efficiently broadcasts a single RGBA color across all pixels in the destination. + fn copy_solid(simd: S, dest: &mut [Self::Numeric], src: [Self::Numeric; 4]) { + simd.vectorize( + #[inline(always)] + || { + let target: &mut [u32] = bytemuck::cast_slice_mut(dest); + target.fill(u32::from_ne_bytes(src)); + }, + ); + } + + /// Creates a painter for rendering gradients in u8 precision. + /// + /// Returns a painter that evaluates the gradient at each pixel position. + fn gradient_painter<'a>( + simd: S, + gradient: &'a EncodedGradient, + t_vals: &'a [f32], + ) -> impl Painter + 'a { + simd.vectorize( + #[inline(always)] + || gradient::GradientPainter::new(simd, gradient, t_vals), + ) + } + + /// Creates a painter for rendering images with bilinear filtering in u8 precision. + /// + /// Returns a painter that samples the image with bilinear interpolation. + fn medium_quality_image_painter<'a>( + simd: S, + image: &'a EncodedImage, + pixmap: &'a Pixmap, + start_x: f64, + start_y: f64, + ) -> impl Painter + 'a { + simd.vectorize( + #[inline(always)] + || BilinearImagePainter::new(simd, image, pixmap, start_x, start_y), + ) + } + + /// Creates a painter for rendering axis-aligned images with bilinear filtering in u8 precision. + /// + /// Returns a painter that samples the image with bilinear interpolation. + /// This is an optimized version for images without skew transformation. + fn plain_medium_quality_image_painter<'a>( + simd: S, + image: &'a EncodedImage, + pixmap: &'a Pixmap, + start_x: f64, + start_y: f64, + ) -> impl Painter + 'a { + simd.vectorize( + #[inline(always)] + || PlainBilinearImagePainter::new(simd, image, pixmap, start_x, start_y), + ) + } + + /// Applies per-pixel mask values to a buffer by multiplying each component. + /// + /// Used for anti-aliasing and clipping effects. Each pixel is multiplied by + /// its corresponding mask value and normalized. + fn apply_mask( + simd: S, + dest: &mut [Self::Numeric], + mut src: impl Iterator, + ) { + simd.vectorize( + #[inline(always)] + || { + for el in dest.chunks_exact_mut(16) { + let loaded = u8x16::from_slice(simd, el); + let mulled = simd.narrow_u16x16( + (simd.widen_u8x16(loaded) * simd.widen_u8x16(src.next().unwrap())) + .div_255(), + ); + mulled.store_slice(el); + } + }, + ); + } + + /// Applies a painter's output to the destination buffer. + /// + /// Delegates to the painter's u8-specific implementation. + #[inline(always)] + fn apply_painter<'a>(_: S, dest: &mut [Self::Numeric], mut painter: impl Painter + 'a) { + painter.paint_u8(dest); + } + + #[inline(always)] + fn apply_tint(simd: S, dest: &mut [Self::Numeric], tint: &Tint) { + let premul = tint.color.premultiply(); + let [r, g, b, a] = premul.components; + let to_u8 = |v: f32| (v * 255.0 + 0.5) as u8; + let color = u32::from_ne_bytes([to_u8(r), to_u8(g), to_u8(b), to_u8(a)]); + let tint_v = u32x8::block_splat(u32x4::splat(simd, color)).to_bytes(); + + simd.vectorize( + #[inline(always)] + || match tint.mode { + TintMode::AlphaMask => { + for chunk in dest.chunks_exact_mut(32) { + let pixel = u8x32::from_slice(simd, chunk); + let alphas = pixel.splat_4th(); + let tinted = tint_v.normalized_mul(alphas); + tinted.store_slice(chunk); + } + } + TintMode::Multiply => { + for chunk in dest.chunks_exact_mut(32) { + let pixel = u8x32::from_slice(simd, chunk); + let tinted = pixel.normalized_mul(tint_v); + tinted.store_slice(chunk); + } + } + }, + ); + } + + /// Composites a solid color onto a buffer using alpha blending. + /// + /// Dispatches to either the masked or unmasked implementation based on the + /// presence of per-pixel alpha masks. + #[inline(always)] + fn alpha_composite_solid( + simd: S, + dest: &mut [Self::Numeric], + src: [Self::Numeric; 4], + alphas: Option<&[u8]>, + ) { + if let Some(alphas) = alphas { + alpha_fill::alpha_composite_solid( + simd, + dest, + src, + cast_slice::(alphas).iter().copied(), + ); + } else { + fill::alpha_composite_solid(simd, dest, src); + } + } + + /// Composites a source buffer onto a destination buffer using alpha blending. + /// + /// Dispatches to either the masked or unmasked implementation based on the + /// presence of per-pixel alpha masks. Each source pixel's alpha determines + /// the blending amount. + fn alpha_composite_buffer( + simd: S, + dest: &mut [Self::Numeric], + src: &[Self::Numeric], + alphas: Option<&[u8]>, + ) { + let src_iter = src.chunks_exact(32).map(|el| u8x32::from_slice(simd, el)); + + if let Some(alphas) = alphas { + alpha_fill::alpha_composite( + simd, + dest, + src_iter, + cast_slice::(alphas).iter().copied(), + ); + } else { + fill::alpha_composite(simd, dest, src_iter); + } + } + + /// Applies a blend mode to composite source pixels onto destination. + /// + /// Dispatches to either the masked or unmasked blend implementation. + /// Handles both color mixing (multiply, screen, etc.) and compositing. + fn blend( + simd: S, + dest: &mut [Self::Numeric], + mut start_x: u16, + start_y: u16, + src: impl Iterator, + blend_mode: BlendMode, + alphas: Option<&[u8]>, + mask: Option<&Mask>, + ) { + let alpha_iter = alphas.map(|a| cast_slice::(a).iter().copied()); + + let mask_iter = mask.map(|m| { + iter::from_fn(|| { + let sample = |x: u16, y: u16| { + if x < m.width() && y < m.height() { + m.sample(x, y) + } else { + 255 + } + }; + + let samples = [ + sample(start_x, start_y), + sample(start_x, start_y + 1), + sample(start_x, start_y + 2), + sample(start_x, start_y + 3), + sample(start_x + 1, start_y), + sample(start_x + 1, start_y + 1), + sample(start_x + 1, start_y + 2), + sample(start_x + 1, start_y + 3), + ]; + + start_x += 2; + + Some(samples) + }) + }); + + match (alpha_iter, mask_iter) { + (Some(alpha_iter), Some(mut mask_iter)) => { + let iter = alpha_iter.map(|a1| { + let a2 = mask_iter.next().unwrap(); + [ + div_255(a1[0] as u16 * a2[0] as u16) as u8, + div_255(a1[1] as u16 * a2[1] as u16) as u8, + div_255(a1[2] as u16 * a2[2] as u16) as u8, + div_255(a1[3] as u16 * a2[3] as u16) as u8, + div_255(a1[4] as u16 * a2[4] as u16) as u8, + div_255(a1[5] as u16 * a2[5] as u16) as u8, + div_255(a1[6] as u16 * a2[6] as u16) as u8, + div_255(a1[7] as u16 * a2[7] as u16) as u8, + ] + }); + alpha_fill::blend(simd, dest, src, blend_mode, iter); + } + (None, Some(mask_iter)) => alpha_fill::blend(simd, dest, src, blend_mode, mask_iter), + (Some(alpha_iter), None) => alpha_fill::blend(simd, dest, src, blend_mode, alpha_iter), + (None, None) => { + fill::blend(simd, dest, src, blend_mode); + } + } + } +} + +mod fill { + //! Alpha compositing and blending operations without per-pixel alpha masks. + //! + //! This module handles the case where we're compositing full opacity pixels, + //! using only the source alpha channel for compositing. + + use crate::fine::Splat4thExt; + use crate::fine::lowp::compose::ComposeExt; + use crate::fine::lowp::mix; + use crate::peniko::{BlendMode, Mix}; + use vello_common::fearless_simd::*; + use vello_common::util::normalized_mul_u8x32; + + /// Applies blend mode compositing to a buffer without per-pixel masks. + pub(super) fn blend>>( + simd: S, + dest: &mut [u8], + src: T, + blend_mode: BlendMode, + ) { + simd.vectorize( + #[inline(always)] + || { + let default_mix = matches!(blend_mode.mix, Mix::Normal); + for (next_dest, next_src) in dest.chunks_exact_mut(32).zip(src) { + let bg_v = u8x32::from_slice(simd, next_dest); + let src_v = if default_mix { + next_src + } else { + mix(next_src, bg_v, blend_mode) + }; + let res = blend_mode.compose(simd, src_v, bg_v, None); + res.store_slice(next_dest); + } + }, + ); + } + + /// Composites a solid color onto a buffer using alpha blending. + /// + /// Uses the "over" operator: `result = src + bg * (1 - src_alpha)` + pub(super) fn alpha_composite_solid(s: S, dest: &mut [u8], src: [u8; 4]) { + s.vectorize( + #[inline(always)] + || { + let one_minus_alpha = 255 - u8x32::splat(s, src[3]); + let src_c = u32x8::splat(s, u32::from_ne_bytes(src)).to_bytes(); + + for next_dest in dest.chunks_exact_mut(64) { + // We process in batches of 64 because loading/storing is much faster this way (at least on NEON), + // but since we widen to u16, we can only work with 256 bits, so we split it up. + let bg_v = u8x64::from_slice(s, next_dest); + let (bg_1, bg_2) = s.split_u8x64(bg_v); + let res_1 = alpha_composite_inner(s, bg_1, src_c, one_minus_alpha); + let res_2 = alpha_composite_inner(s, bg_2, src_c, one_minus_alpha); + let combined = s.combine_u8x32(res_1, res_2); + combined.store_slice(next_dest); + } + }, + ); + } + + /// Composites a buffer of colors onto another buffer using alpha blending. + /// + /// Each source pixel is composited individually based on its alpha channel. + pub(super) fn alpha_composite>>( + simd: S, + dest: &mut [u8], + src: T, + ) { + simd.vectorize( + #[inline(always)] + || { + for (next_dest, next_src) in dest.chunks_exact_mut(32).zip(src) { + let one_minus_alpha = 255 - next_src.splat_4th(); + let bg_v = u8x32::from_slice(simd, next_dest); + let res = alpha_composite_inner(simd, bg_v, next_src, one_minus_alpha); + res.store_slice(next_dest); + } + }, + ); + } + + /// Performs the core alpha compositing calculation. + /// + /// Formula: `result = src + bg * (1 - src_alpha)` + /// This implements the Porter-Duff "source over" operator. + #[inline(always)] + fn alpha_composite_inner( + s: S, + bg: u8x32, + src: u8x32, + one_minus_alpha: u8x32, + ) -> u8x32 { + s.narrow_u16x32(normalized_mul_u8x32(bg, one_minus_alpha)) + src + } +} + +mod alpha_fill { + //! Alpha compositing and blending operations with per-pixel alpha masks. + //! + //! This module handles compositing when each pixel has an additional mask value + //! (e.g., from anti-aliasing or clip masks) that modulates the source alpha. + + use crate::fine::Splat4thExt; + use crate::fine::lowp::compose::ComposeExt; + use crate::fine::lowp::{extract_masks, mix}; + use crate::peniko::{BlendMode, Mix}; + use vello_common::fearless_simd::*; + use vello_common::util::{Div255Ext, normalized_mul_u8x32}; + + /// Applies blend mode compositing with per-pixel alpha masks. + pub(super) fn blend>>( + simd: S, + dest: &mut [u8], + src: T, + blend_mode: BlendMode, + alphas: impl Iterator, + ) { + simd.vectorize( + #[inline(always)] + || { + let default_mix = matches!(blend_mode.mix, Mix::Normal); + + for ((next_bg, next_mask), next_src) in + dest.chunks_exact_mut(32).zip(alphas).zip(src) + { + let bg_v = u8x32::from_slice(simd, next_bg); + let src_c = if default_mix { + next_src + } else { + mix(next_src, bg_v, blend_mode) + }; + let masks = extract_masks(simd, &next_mask); + let res = blend_mode.compose(simd, src_c, bg_v, Some(masks)); + + res.store_slice(next_bg); + } + }, + ); + } + + /// Composites a solid color with per-pixel alpha masks. + /// + /// Combines source alpha with mask values: `effective_alpha = src_alpha * mask / 255` + #[inline(always)] + pub(super) fn alpha_composite_solid( + s: S, + dest: &mut [u8], + src: [u8; 4], + alphas: impl Iterator, + ) { + s.vectorize( + #[inline(always)] + || { + let src_a = u8x32::splat(s, src[3]); + let src_c = u32x8::splat(s, u32::from_ne_bytes(src)).to_bytes(); + let one = u8x32::splat(s, 255); + + for (next_bg, next_mask) in dest.chunks_exact_mut(32).zip(alphas) { + alpha_composite_inner(s, next_bg, &next_mask, src_c, src_a, one); + } + }, + ); + } + + /// Composites a buffer of colors with per-pixel alpha masks. + /// + /// Each pixel's source alpha is modulated by its corresponding mask value. + #[inline(always)] + pub(super) fn alpha_composite>>( + simd: S, + dest: &mut [u8], + src: T, + alphas: impl Iterator, + ) { + simd.vectorize( + #[inline(always)] + || { + let one = u8x32::splat(simd, 255); + + for ((next_dest, next_mask), next_src) in + dest.chunks_exact_mut(32).zip(alphas).zip(src) + { + let src_a = next_src.splat_4th(); + alpha_composite_inner(simd, next_dest, &next_mask, next_src, src_a, one); + } + }, + ); + } + + /// Performs alpha compositing with mask modulation. + /// + /// Formula: `result = src * mask + bg * (1 - src_alpha * mask)` + /// The mask value modulates both the source contribution and the inverse alpha. + #[inline(always)] + fn alpha_composite_inner( + s: S, + dest: &mut [u8], + masks: &[u8; 8], + src_c: u8x32, + src_a: u8x32, + one: u8x32, + ) { + s.vectorize( + #[inline(always)] + || { + let bg_v = u8x32::from_slice(s, dest); + + let mask_v = extract_masks(s, masks); + let inv_src_a_mask_a = one - s.narrow_u16x32(normalized_mul_u8x32(src_a, mask_v)); + + let p1 = s.widen_u8x32(bg_v) * s.widen_u8x32(inv_src_a_mask_a); + let p2 = s.widen_u8x32(src_c) * s.widen_u8x32(mask_v); + let res = s.narrow_u16x32((p1 + p2).div_255()); + + res.store_slice(dest); + }, + ); + } +} + +/// Applies blend mode mixing by converting to f32, mixing, then converting back to u8. +/// +/// TODO: Add a proper lowp mix pipeline that operates entirely in integer space +/// for better performance (currently converts through f32 which is slower). +fn mix(src_c: u8x32, bg_c: u8x32, blend_mode: BlendMode) -> u8x32 { + let to_f32 = |val: u8x32| { + let (a, b) = src_c.simd.split_u8x32(val); + let mut a = u8_to_f32(a); + let mut b = u8_to_f32(b); + a *= f32x16::splat(src_c.simd, 1.0 / 255.0); + b *= f32x16::splat(src_c.simd, 1.0 / 255.0); + (a, b) + }; + + let to_u8 = |val1: f32x16, val2: f32x16| { + let val1 = + f32_to_u8(f32x16::splat(val1.simd, 255.0).mul_add(val1, f32x16::splat(val1.simd, 0.5))); + let val2 = + f32_to_u8(f32x16::splat(val2.simd, 255.0).mul_add(val2, f32x16::splat(val2.simd, 0.5))); + + val1.simd.combine_u8x16(val1, val2) + }; + + let (mut src_1, mut src_2) = to_f32(src_c); + let (bg_1, bg_2) = to_f32(bg_c); + + src_1 = highp::blend::mix(src_1, bg_1, blend_mode); + src_2 = highp::blend::mix(src_2, bg_2, blend_mode); + + to_u8(src_1, src_2) +} + +/// Expands 8 mask bytes into a 32-byte SIMD vector where each pixel's 4 components +/// share the same mask value (each of 8 mask values is repeated 4 times). +/// +/// Input: [m0, m1, m2, m3, m4, m5, m6, m7] +/// Output: [m0, m0, m0, m0, m1, m1, m1, m1, ..., m7, m7, m7, m7] +#[inline(always)] +fn extract_masks(simd: S, masks: &[u8; 8]) -> u8x32 { + let m1 = u32x4::splat(simd, u32::from_ne_bytes(masks[0..4].try_into().unwrap())).to_bytes(); + let m2 = u32x4::splat(simd, u32::from_ne_bytes(masks[4..8].try_into().unwrap())).to_bytes(); + + let zipped1 = m1.zip_low(m1); + let zipped1 = zipped1.zip_low(zipped1); + + let zipped2 = m2.zip_low(m2); + let zipped2 = zipped2.zip_low(zipped2); + + simd.combine_u8x16(zipped1, zipped2) +} + +/// Copies color data from the scratch buffer to the output region (scalar fallback). +/// +/// The scratch buffer stores pixels in column-major order for SIMD efficiency, +/// while the region uses row-major order for output. +#[inline(always)] +fn pack(region: &mut Region<'_>, blend_buf: &[u8]) { + for y in 0..Tile::HEIGHT { + for (x, pixel) in region + .row_mut(y) + .chunks_exact_mut(COLOR_COMPONENTS) + .enumerate() + { + let idx = COLOR_COMPONENTS * (usize::from(Tile::HEIGHT) * x + usize::from(y)); + pixel.copy_from_slice(&blend_buf[idx..][..COLOR_COMPONENTS]); + } + } +} + +/// Copies color data from the output region to the scratch buffer. +/// +/// Converts from row-major (region) to column-major (scratch buffer) layout. +/// This is the inverse operation of `pack`. +#[inline(always)] +fn unpack(region: &mut Region<'_>, blend_buf: &mut [u8]) { + for y in 0..Tile::HEIGHT { + for (x, pixel) in region.row_mut(y).chunks_exact(COLOR_COMPONENTS).enumerate() { + let idx = COLOR_COMPONENTS * (usize::from(Tile::HEIGHT) * x + usize::from(y)); + blend_buf[idx..][..COLOR_COMPONENTS].copy_from_slice(pixel); + } + } +} + +/// SIMD-optimized version of `pack` for full-size tiles using interleaved loads. +/// +/// Uses `load_interleaved_128` to efficiently transpose and copy data from column-major +/// scratch buffer to row-major output region. Performance characteristics are highly +/// architecture-dependent: +/// - On NEON: ~3x faster than scalar `pack` +/// - On fallback SIMD: ~3x slower than scalar `pack` +/// +/// TODO: Consider runtime detection to fall back to scalar on non-NEON architectures. +#[inline(always)] +fn pack_block(simd: S, region: &mut Region<'_>, mut buf: &[u8]) { + buf = &buf[..SCRATCH_BUF_SIZE]; + + const CHUNK_LENGTH: usize = 64; + const SLICE_WIDTH: usize = WideTile::WIDTH as usize * COLOR_COMPONENTS; + + let region_areas = region.areas(); + let [s1, s2, s3, s4] = region_areas; + let dest_slices: &mut [&mut [u8; SLICE_WIDTH]; 4] = &mut [ + (*s1).try_into().unwrap(), + (*s2).try_into().unwrap(), + (*s3).try_into().unwrap(), + (*s4).try_into().unwrap(), + ]; + + for (idx, col) in buf.chunks_exact(CHUNK_LENGTH).enumerate() { + let dest_idx = idx * CHUNK_LENGTH / 4; + + let casted: &[u32; 16] = cast_slice::(col).try_into().unwrap(); + + let loaded = simd.load_interleaved_128_u32x16(casted).to_bytes(); + let (loaded_lo, loaded_hi) = simd.split_u8x64(loaded); + let (loaded_1, loaded_2) = simd.split_u8x32(loaded_lo); + let (loaded_3, loaded_4) = simd.split_u8x32(loaded_hi); + loaded_1.store_slice(&mut dest_slices[0][dest_idx..][..16]); + loaded_2.store_slice(&mut dest_slices[1][dest_idx..][..16]); + loaded_3.store_slice(&mut dest_slices[2][dest_idx..][..16]); + loaded_4.store_slice(&mut dest_slices[3][dest_idx..][..16]); + } +} + +/// The pendant to `pack_block`, but for unpacking. +/// +/// See the [`unpack`] method for more information. +#[inline(always)] +fn unpack_block(simd: S, region: &mut Region<'_>, buf: &mut [u8]) { + let buf: &mut [f32] = bytemuck::cast_slice_mut(&mut buf[..SCRATCH_BUF_SIZE]); + const CHUNK_LENGTH: usize = 16; + + let region_areas = region.areas(); + let [s1, s2, s3, s4] = region_areas; + + for (idx, col) in buf.as_chunks_mut::().0.iter_mut().enumerate() { + let src_idx = idx * CHUNK_LENGTH; + + // Note: We experimented with using u32 vs. f32 for this, but it seems like for some reason + // f32 works better on M1, while on M4 they are the same. Probably worth doing more + // benchmarks on different systems. + let r0 = f32x4::from_bytes(u8x16::from_slice(simd, &s1[src_idx..][..16])); + let r1 = f32x4::from_bytes(u8x16::from_slice(simd, &s2[src_idx..][..16])); + let r2 = f32x4::from_bytes(u8x16::from_slice(simd, &s3[src_idx..][..16])); + let r3 = f32x4::from_bytes(u8x16::from_slice(simd, &s4[src_idx..][..16])); + + let combined = simd.combine_f32x8(simd.combine_f32x4(r0, r1), simd.combine_f32x4(r2, r3)); + + simd.store_interleaved_128_f32x16(combined, col); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloc::vec; + use alloc::vec::Vec; + use vello_common::fearless_simd::dispatch; + + fn test_pack_unpack_roundtrip( + pack_fn: impl FnOnce(&mut Region<'_>, &[u8]), + unpack_fn: impl FnOnce(&mut Region<'_>, &mut [u8]), + ) { + let width = WideTile::WIDTH; + let height = Tile::HEIGHT; + + // Just some pseudo-random numbers. + let blend_buf = (0..SCRATCH_BUF_SIZE) + .map(|n| ((n * 7 + 13) % 256) as u8) + .collect::>(); + + let mut region_data = vec![0_u8; width as usize * height as usize * COLOR_COMPONENTS]; + let row_len = width as usize * COLOR_COMPONENTS; + let (r0, rest) = region_data.split_at_mut(row_len); + let (r1, rest) = rest.split_at_mut(row_len); + let (r2, r3) = rest.split_at_mut(row_len); + let mut region = Region::new([r0, r1, r2, r3], 0, 0, width, height); + + // First pack. + pack_fn(&mut region, &blend_buf); + + // Now reverse the process, unpacking into a new buffer. + let mut unpacked_buf = vec![0_u8; SCRATCH_BUF_SIZE]; + unpack_fn(&mut region, &mut unpacked_buf); + + assert_eq!(&blend_buf, &unpacked_buf); + } + + #[test] + fn pack_unpack_roundtrip() { + test_pack_unpack_roundtrip(pack, unpack); + } + + #[test] + fn pack_block_unpack_block_roundtrip() { + dispatch!(Level::try_detect().unwrap_or(Level::baseline()), simd => { + test_pack_unpack_roundtrip( + |region, buf| simd.vectorize(|| pack_block(simd, region, buf)), + |region, buf| simd.vectorize(|| unpack_block(simd, region, buf)), + ); + }); + } +} diff --git a/vendor/vello_cpu/src/fine/mod.rs b/vendor/vello_cpu/src/fine/mod.rs new file mode 100644 index 0000000000..c16c8282df --- /dev/null +++ b/vendor/vello_cpu/src/fine/mod.rs @@ -0,0 +1,1127 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Fine rasterization stage of the rendering pipeline. +//! +//! This module implements the fine rasterization phase, which processes tiles at the pixel level. +//! It supports both high-precision (f32) and low-precision (u8) rendering paths, along with +//! various paint types including solid colors, gradients, images, and blurred rounded rectangles. + +mod common; +mod highp; +mod lowp; + +use crate::fine::common::gradient::linear::SimdLinearKind; +use crate::fine::common::gradient::radial::SimdRadialKind; +use crate::fine::common::gradient::sweep::SimdSweepKind; +use crate::fine::common::gradient::{GradientPainter, calculate_t_vals}; +use crate::fine::common::image::{FilteredImagePainter, NNImagePainter, PlainNNImagePainter}; +use crate::fine::common::rounded_blurred_rect::BlurredRoundedRectFiller; +use crate::layer_manager::LayerManager; +use crate::peniko::{BlendMode, ImageQuality}; +use crate::region::Region; +use crate::util::EncodedImageExt; +use alloc::vec; +use alloc::vec::Vec; +use core::fmt::Debug; +use core::iter; +use vello_common::coarse::{Cmd, CommandAttrs, WideTile}; +use vello_common::encode::{ + EncodedBlurredRoundedRectangle, EncodedGradient, EncodedImage, EncodedKind, EncodedPaint, +}; +use vello_common::fearless_simd::{ + Bytes, Simd, SimdBase, SimdFloat, SimdInt, SimdInto, f32x4, f32x8, f32x16, u8x16, u8x32, u32x4, + u32x8, +}; +use vello_common::filter_effects::Filter; +use vello_common::kurbo::Affine; +use vello_common::mask::Mask; +use vello_common::paint::{ImageResolver, ImageSource, Paint, PremulColor, Tint}; +use vello_common::pixmap::Pixmap; +use vello_common::simd::Splat4thExt; +use vello_common::tile::Tile; +use vello_common::util::f32_to_u8; + +pub use highp::F32Kernel; +pub use lowp::U8Kernel; + +/// Offset to shift from pixel corner to pixel center for sampling. +const PIXEL_CENTER_OFFSET: f64 = 0.5; + +/// Number of color components per pixel (RGBA). +pub(crate) const COLOR_COMPONENTS: usize = 4; + +/// Number of color components in a single column of a tile (height * components). +pub(crate) const TILE_HEIGHT_COMPONENTS: usize = Tile::HEIGHT as usize * COLOR_COMPONENTS; + +/// Size of the scratch buffer used for intermediate rendering operations. +/// Sized to hold a full wide tile with all color components. +pub const SCRATCH_BUF_SIZE: usize = + WideTile::WIDTH as usize * Tile::HEIGHT as usize * COLOR_COMPONENTS; + +/// Type alias for a scratch buffer that can hold a full wide tile's worth of data. +pub type ScratchBuf = [F; SCRATCH_BUF_SIZE]; + +/// Trait for numeric types used in fine rasterization. +/// +/// This trait abstracts over `f32` and `u8` to allow the same rendering logic +/// to work with both high-precision (floating-point) and low-precision (integer) +/// representations. This enables performance optimizations while maintaining accuracy +/// where needed. +pub trait Numeric: Copy + Default + Clone + Debug + PartialEq + Send + Sync + 'static { + /// The zero value for this numeric type (0.0 for f32, 0 for u8). + const ZERO: Self; + + /// The maximum opacity value for this numeric type (1.0 for f32, 255 for u8). + const ONE: Self; +} + +impl Numeric for f32 { + const ZERO: Self = 0.0; + const ONE: Self = 1.0; +} + +impl Numeric for u8 { + const ZERO: Self = 0; + const ONE: Self = 255; +} + +/// Trait for SIMD vector types that can convert between f32 and u8 representations. +/// +/// This trait enables efficient batch conversions between different numeric representations +/// during rendering operations, supporting both high-precision and low-precision rendering paths. +pub trait NumericVec: Copy + Clone + Send + Sync { + /// Convert from a SIMD vector of f32 values to this type. + fn from_f32(simd: S, val: f32x16) -> Self; + + /// Convert from a SIMD vector of u8 values to this type. + fn from_u8(simd: S, val: u8x16) -> Self; +} + +impl NumericVec for f32x16 { + #[inline(always)] + fn from_f32(_: S, val: Self) -> Self { + val + } + + #[inline(always)] + fn from_u8(simd: S, val: u8x16) -> Self { + let converted = u8_to_f32(val); + converted * Self::splat(simd, 1.0 / 255.0) + } +} + +impl NumericVec for u8x16 { + #[inline(always)] + fn from_f32(simd: S, val: f32x16) -> Self { + let v1 = f32x16::splat(simd, 255.0); + let v2 = f32x16::splat(simd, 0.5); + let mulled = val.mul_add(v1, v2); + + f32_to_u8(mulled) + } + + #[inline(always)] + fn from_u8(_: S, val: Self) -> Self { + val + } +} + +/// Convert a SIMD vector of u8 values to f32 values. +/// +/// This function efficiently converts 16 u8 values to their f32 equivalents using SIMD operations, +/// preserving the values without normalization (i.e., 255 becomes 255.0, not 1.0). +#[inline(always)] +pub(crate) fn u8_to_f32(val: u8x16) -> f32x16 { + let simd = val.simd; + let zeroes = u8x16::splat(simd, 0); + + let zip1 = simd.zip_high_u8x16(val, zeroes); + let zip2 = simd.zip_low_u8x16(val, zeroes); + + let p1 = simd + .zip_low_u8x16(zip2, zeroes) + .bitcast::>() + .to_float::>(); + let p2 = simd + .zip_high_u8x16(zip2, zeroes) + .bitcast::>() + .to_float::>(); + let p3 = simd + .zip_low_u8x16(zip1, zeroes) + .bitcast::>() + .to_float::>(); + let p4 = simd + .zip_high_u8x16(zip1, zeroes) + .bitcast::>() + .to_float::>(); + + simd.combine_f32x8(simd.combine_f32x4(p1, p2), simd.combine_f32x4(p3, p4)) +} + +/// Trait for SIMD vector types used in compositing and blending operations. +/// +/// This trait abstracts over different SIMD vector widths (f32x16 for high-precision, +/// u8x32 for low-precision) to enable efficient batch processing of pixel data during +/// blending and compositing. +pub trait CompositeType: Copy + Clone + Send + Sync { + /// The number of numeric values this composite type can hold. + const LENGTH: usize; + + /// Load values from a slice into this composite type. + fn from_slice(simd: S, slice: &[N]) -> Self; + + /// Create a composite type by repeating a single RGBA color across all elements. + fn from_color(simd: S, color: [N; 4]) -> Self; +} + +impl CompositeType for f32x16 { + const LENGTH: usize = 16; + + #[inline(always)] + fn from_slice(simd: S, slice: &[f32]) -> Self { + >::from_slice(simd, slice) + } + + #[inline(always)] + fn from_color(simd: S, color: [f32; 4]) -> Self { + Self::block_splat(f32x4::from_slice(simd, &color[..])) + } +} + +impl CompositeType for u8x32 { + const LENGTH: usize = 32; + + #[inline(always)] + fn from_slice(simd: S, slice: &[u8]) -> Self { + >::from_slice(simd, slice) + } + + #[inline(always)] + fn from_color(simd: S, color: [u8; 4]) -> Self { + u32x8::block_splat(u32x4::splat(simd, u32::from_ne_bytes(color))).to_bytes() + } +} + +/// A kernel for performing fine rasterization. +/// +/// This trait defines the interface for tile-level rendering operations, abstracting over +/// different numeric precisions (f32 vs u8). Implementations provide the low-level pixel +/// manipulation, blending, and painting operations needed to render tiles. +/// +/// The two main implementations are: +/// - [`F32Kernel`]: High-precision rendering using 32-bit floating-point values +/// - [`U8Kernel`]: Low-precision rendering using 8-bit integer values +pub trait FineKernel: Send + Sync + 'static { + /// The basic underlying numerical type of the kernel (f32 or u8). + type Numeric: Numeric; + + /// The SIMD composite type used for efficient batch blending and compositing operations. + type Composite: CompositeType; + + /// The SIMD vector type used for conversions between u8 and f32 representations. + type NumericVec: NumericVec; + + /// Extract and convert a premultiplied color to the kernel's numeric type. + /// + /// Converts RGBA components from the standard premultiplied color format to + /// the kernel's internal representation (e.g., 0.0-1.0 for f32, 0-255 for u8). + fn extract_color(color: PremulColor) -> [Self::Numeric; 4]; + + /// Pack the blend buffer contents into the output region. + /// + /// Converts from the internal scratch buffer format to the output tile format, + /// writing the results to the provided region. + fn pack(simd: S, region: &mut Region<'_>, blend_buf: &[Self::Numeric]); + + /// Unpack the region contents back into the blend buffer. + /// + /// Performs the reverse of `pack`, reading pixel data from the tile region + /// and loading it into the scratch buffer for further processing. + fn unpack(simd: S, region: &mut Region<'_>, blend_buf: &mut [Self::Numeric]); + + /// Apply a filter to a layer. + /// + /// This is used for applying filters to whole layers, which is necessary for + /// spatial filters (like blur) that need to access neighboring pixels. The filter + /// is applied in-place to the provided pixmap. + /// + /// The transform parameter is used to scale filter parameters based on the current + /// transformation matrix (e.g., zoom level), ensuring filters look consistent + /// regardless of scale. + fn filter_layer( + pixmap: &mut Pixmap, + filter: &Filter, + layer_manager: &mut LayerManager, + transform: Affine, + ); + + /// Fill the target buffer with a solid color. + /// + /// Efficiently replicates the given RGBA color across all pixels in the target buffer. + fn copy_solid(simd: S, target: &mut [Self::Numeric], color: [Self::Numeric; 4]); + /// Create a painter for rendering gradients. + /// + /// Returns a painter that can render linear, radial, or sweep gradients based on + /// pre-computed t values (gradient interpolation parameters). + fn gradient_painter<'a>( + simd: S, + gradient: &'a EncodedGradient, + t_vals: &'a [f32], + ) -> impl Painter + 'a { + simd.vectorize( + #[inline(always)] + || GradientPainter::new(simd, gradient, t_vals), + ) + } + + /// Create a painter for rendering gradients with undefined region support. + /// + /// Similar to `gradient_painter`, but with support for masking undefined locations + /// (used for radial gradients that may have mathematically undefined regions). + /// + /// This is intentionally a duplicate of the default [`FineKernel::gradient_painter`] + /// implementation--the `U8Kernel` overrides that method, but not this one. + fn gradient_painter_with_undefined<'a>( + simd: S, + gradient: &'a EncodedGradient, + t_vals: &'a [f32], + ) -> impl Painter + 'a { + simd.vectorize( + #[inline(always)] + || GradientPainter::new(simd, gradient, t_vals), + ) + } + /// Create a painter for rendering axis-aligned nearest-neighbor images. + /// + /// Optimized painter for images with `Low` quality and no skewing component in their + /// transform. This is the fastest image rendering path. + fn plain_nn_image_painter<'a>( + simd: S, + image: &'a EncodedImage, + pixmap: &'a Pixmap, + start_x: f64, + start_y: f64, + ) -> impl Painter + 'a { + simd.vectorize( + #[inline(always)] + || PlainNNImagePainter::new(simd, image, pixmap, start_x, start_y), + ) + } + + /// Create a painter for rendering nearest-neighbor images with transforms. + /// + /// Similar to `plain_nn_image_painter`, but supports arbitrary affine transforms + /// including skewing and rotation. + fn nn_image_painter<'a>( + simd: S, + image: &'a EncodedImage, + pixmap: &'a Pixmap, + start_x: f64, + start_y: f64, + ) -> impl Painter + 'a { + simd.vectorize( + #[inline(always)] + || NNImagePainter::new(simd, image, pixmap, start_x, start_y), + ) + } + + /// Create a painter for rendering images with `Medium` quality filtering. + /// + /// Uses bilinear filtering for smoother appearance than nearest-neighbor. + fn medium_quality_image_painter<'a>( + simd: S, + image: &'a EncodedImage, + pixmap: &'a Pixmap, + start_x: f64, + start_y: f64, + ) -> impl Painter + 'a { + simd.vectorize( + #[inline(always)] + || FilteredImagePainter::::new(simd, image, pixmap, start_x, start_y), + ) + } + + /// Create a painter for rendering axis-aligned images with `Medium` quality filtering. + /// + /// Optimized painter for images with bilinear filtering and no skewing component. + fn plain_medium_quality_image_painter<'a>( + simd: S, + image: &'a EncodedImage, + pixmap: &'a Pixmap, + start_x: f64, + start_y: f64, + ) -> impl Painter + 'a { + simd.vectorize( + #[inline(always)] + || FilteredImagePainter::::new(simd, image, pixmap, start_x, start_y), + ) + } + + /// Create a painter for rendering images with `High` quality filtering. + /// + /// Uses high-quality filtering for the best visual appearance. + fn high_quality_image_painter<'a>( + simd: S, + image: &'a EncodedImage, + pixmap: &'a Pixmap, + start_x: f64, + start_y: f64, + ) -> impl Painter + 'a { + simd.vectorize( + #[inline(always)] + || FilteredImagePainter::::new(simd, image, pixmap, start_x, start_y), + ) + } + + /// Create a painter for rendering blurred rounded rectangles. + /// + /// Efficiently renders rounded rectangles with gaussian blur applied, + /// computing the blur analytically rather than as a post-process. + fn blurred_rounded_rectangle_painter( + simd: S, + rect: &EncodedBlurredRoundedRectangle, + start_x: f64, + start_y: f64, + ) -> impl Painter { + simd.vectorize( + #[inline(always)] + || BlurredRoundedRectFiller::new(simd, rect, start_x, start_y), + ) + } + /// Apply a mask to the destination buffer. + /// + /// Multiplies each pixel in the destination by the corresponding mask value, + /// effectively masking out or reducing the opacity of pixels. + fn apply_mask(simd: S, dest: &mut [Self::Numeric], src: impl Iterator); + + /// Apply a painter to render content into the destination buffer. + /// + /// Invokes the painter to generate pixel values and writes them to the destination. + fn apply_painter<'a>(simd: S, dest: &mut [Self::Numeric], painter: impl Painter + 'a); + + /// Apply an image tint to an already-painted buffer. + /// + /// This is called as a post-pass after `apply_painter`, only when a tint is + /// present. Keeping tint application out of the per-pixel iterator avoids + /// regressing the non-tinted fast path. + fn apply_tint(simd: S, dest: &mut [Self::Numeric], tint: &Tint); + + /// Perform alpha compositing with a solid color over the target buffer. + /// + /// Blends a solid RGBA color over the existing contents using standard alpha compositing + /// (Porter-Duff source-over). Optionally applies additional per-pixel alpha values. + fn alpha_composite_solid( + simd: S, + target: &mut [Self::Numeric], + src: [Self::Numeric; 4], + alphas: Option<&[u8]>, + ); + + /// Perform alpha compositing with a source buffer over the destination buffer. + /// + /// Blends the source buffer contents over the destination using standard alpha compositing. + /// Optionally applies additional per-pixel alpha values. + fn alpha_composite_buffer( + simd: S, + dest: &mut [Self::Numeric], + src: &[Self::Numeric], + alphas: Option<&[u8]>, + ); + + /// Blend the source into the destination with a specified blend mode. + /// + /// Applies advanced blending operations (e.g., multiply, screen, overlay) as specified + /// by the blend mode. Optionally applies additional per-pixel alpha values. + fn blend( + simd: S, + dest: &mut [Self::Numeric], + start_x: u16, + start_y: u16, + src: impl Iterator, + blend_mode: BlendMode, + alphas: Option<&[u8]>, + mask: Option<&Mask>, + ); +} + +/// Fine rasterizer for processing tiles at the pixel level. +/// +/// This structure maintains the state and scratch buffers needed for tile-based rendering. +/// It processes rendering commands and manages a stack of blend buffers for layer composition. +#[derive(Debug)] +pub struct Fine> { + /// The (x, y) coordinates of the currently active wide tile being rendered. + pub(crate) wide_coords: (u16, u16), + + /// Stack of blend buffers for managing layers and composition. + /// + /// Each layer pushes a new buffer onto this stack, and layers are composited + /// by popping and blending with the buffer below. + pub(crate) blend_buf: Vec>, + + /// Intermediate buffer used by painters to store generated pixel data before compositing. + pub(crate) paint_buf: ScratchBuf, + + /// Buffer for storing gradient interpolation parameters (t values). + /// + /// Gradients pre-compute these values for efficiency before color lookup. + pub(crate) f32_buf: Vec, + + /// The SIMD context used for vectorized operations. + pub(crate) simd: S, +} + +impl> Fine { + /// Create a new fine rasterizer with the given SIMD context. + /// + /// Initializes all scratch buffers and sets up the initial blend buffer. + pub fn new(simd: S) -> Self { + Self { + simd, + wide_coords: (0, 0), + blend_buf: vec![[T::Numeric::ZERO; SCRATCH_BUF_SIZE]], + f32_buf: vec![0.0; SCRATCH_BUF_SIZE / 4], + paint_buf: [T::Numeric::ZERO; SCRATCH_BUF_SIZE], + } + } + + /// Set the coordinates of the wide tile currently being rendered. + /// + /// This is used by painters and other operations to compute absolute pixel positions. + pub fn set_coords(&mut self, x: u16, y: u16) { + self.wide_coords = (x, y); + } + + /// Clear the current blend buffer to a solid color. + /// + /// This efficiently fills the entire buffer with the given premultiplied color. + pub fn clear(&mut self, premul_color: PremulColor) { + let converted_color = T::extract_color(premul_color); + let blend_buf = self.blend_buf.last_mut().unwrap(); + + T::copy_solid(self.simd, blend_buf, converted_color); + } + + /// Writes the current blend buffer contents to the output region. + /// + /// This copies pixel data from the internal scratch buffer to the tile region, + /// converting the layout from the internal representation to the output format. + pub fn pack(&self, region: &mut Region<'_>) { + let blend_buf = self.blend_buf.last().unwrap(); + + T::pack(self.simd, region, blend_buf); + } + + /// Reads the region contents back into the blend buffer. + /// + /// This copies pixel data from the tile region to the internal scratch buffer, + /// performing the reverse operation of `pack`. This is typically used when a layer + /// needs to be read back for further processing. + pub fn unpack(&mut self, region: &mut Region<'_>) { + let blend_buf = self.blend_buf.last_mut().unwrap(); + + T::unpack(self.simd, region, blend_buf); + } + + /// Apply a filter to a layer. + /// + /// This applies the filter using the kernel's implementation, mutating the layer. + pub fn filter_layer( + &self, + pixmap: &mut Pixmap, + filter: &Filter, + layer_manager: &mut LayerManager, + transform: Affine, + ) { + T::filter_layer(pixmap, filter, layer_manager, transform); + } + + /// Execute a rendering command on the current tile. + /// + /// This is the main dispatch method that processes different command types including + /// fills, clips, blends, filters, masks, and buffer operations. + pub(crate) fn run_cmd( + &mut self, + cmd: &Cmd, + alphas: &[u8], + paints: &[EncodedPaint], + image_resolver: &dyn ImageResolver, + attrs: &CommandAttrs, + ) { + match cmd { + Cmd::Fill(f) => { + let fill_attrs = &attrs.fill[f.attrs_idx as usize]; + self.fill( + usize::from(f.x), + usize::from(f.width), + &fill_attrs.paint, + fill_attrs.blend_mode, + paints, + image_resolver, + None, + fill_attrs.mask.as_ref(), + ); + } + Cmd::AlphaFill(s) => { + let fill_attrs = &attrs.fill[s.attrs_idx as usize]; + let alpha_idx = fill_attrs.alpha_idx(s.alpha_offset) as usize; + self.fill( + usize::from(s.x), + usize::from(s.width), + &fill_attrs.paint, + fill_attrs.blend_mode, + paints, + image_resolver, + Some(&alphas[alpha_idx..]), + fill_attrs.mask.as_ref(), + ); + } + Cmd::Filter(_filter, _) => { + // TODO: Apply non-spatial filters here; spatial filters need layer-level processing + // + // Spatial filters (e.g., Gaussian blur) need neighboring pixels and must be + // rendered to a pixmap for layer-level processing. Non-spatial effects (e.g., + // color matrix, component transfer) can be processed here directly on the + // blend buffer per-pixel as wide commands. + } + Cmd::PushBuf(_layer_kind, _) => { + self.blend_buf.push([T::Numeric::ZERO; SCRATCH_BUF_SIZE]); + } + Cmd::PopBuf => { + self.blend_buf.pop(); + } + Cmd::ClipFill(cf) => { + self.clip(cf.x as usize, cf.width as usize, None); + } + Cmd::ClipStrip(cs) => { + let clip_attrs = &attrs.clip[cs.attrs_idx as usize]; + let alpha_idx = clip_attrs.alpha_idx(cs.alpha_offset) as usize; + self.clip(cs.x as usize, cs.width as usize, Some(&alphas[alpha_idx..])); + } + Cmd::Blend(b) => self.blend(*b), + Cmd::Mask(m) => { + let start_x = self.wide_coords.0 * WideTile::WIDTH; + let start_y = self.wide_coords.1 * Tile::HEIGHT; + + let blend_buf = self.blend_buf.last_mut().unwrap(); + + let width = (blend_buf.len() / (Tile::HEIGHT as usize * COLOR_COMPONENTS)) as u16; + let y = start_y as u32 + u32x4::from_slice(self.simd, &[0, 1, 2, 3]); + + let iter = (start_x..(start_x + width)).map(|x| { + let x_in_range = x < m.width(); + + macro_rules! sample { + ($idx:expr) => { + if x_in_range && (y[$idx] as u16) < m.height() { + m.sample(x, y[$idx] as u16) + } else { + 0 + } + }; + } + + let s1 = sample!(0); + let s2 = sample!(1); + let s3 = sample!(2); + let s4 = sample!(3); + + let samples = u8x16::from_slice( + self.simd, + &[ + s1, s1, s1, s1, s2, s2, s2, s2, s3, s3, s3, s3, s4, s4, s4, s4, + ], + ); + T::NumericVec::from_u8(self.simd, samples) + }); + + T::apply_mask(self.simd, blend_buf, iter); + } + Cmd::Opacity(o) => { + if *o != 1.0 { + let blend_buf = self.blend_buf.last_mut().unwrap(); + + T::apply_mask( + self.simd, + blend_buf, + iter::repeat(T::NumericVec::from_f32( + self.simd, + f32x16::splat(self.simd, *o), + )), + ); + } + } + Cmd::PushZeroClip(_) | Cmd::PopZeroClip => { + // These commands are handled by the dispatcher and should not reach fine rasterization + unreachable!(); + } + Cmd::BatchEnd => {} + } + } + + /// Fill a horizontal strip within the current tile using the given paint. + /// + /// This is the core painting method that handles solid colors, gradients, images, + /// and blurred rounded rectangles. It applies the paint starting at the given x + /// coordinate with the specified width, using the provided blend mode. + /// + /// Note: For short strip segments, benchmarks showed that not inlining this method + /// leads to significantly worse performance. + pub fn fill( + &mut self, + x: usize, + width: usize, + fill: &Paint, + blend_mode: BlendMode, + encoded_paints: &[EncodedPaint], + image_resolver: &dyn ImageResolver, + alphas: Option<&[u8]>, + mask: Option<&Mask>, + ) { + let blend_buf = &mut self.blend_buf.last_mut().unwrap()[x * TILE_HEIGHT_COMPONENTS..] + [..TILE_HEIGHT_COMPONENTS * width]; + let default_blend = blend_mode == BlendMode::default(); + + match fill { + Paint::Solid(color) => { + let color = T::extract_color(*color); + + // If color is completely opaque, we can just directly override + // the blend buffer. + if color[3] == T::Numeric::ONE + && default_blend + && alphas.is_none() + && mask.is_none() + { + T::copy_solid(self.simd, blend_buf, color); + + return; + } + + if default_blend && mask.is_none() { + T::alpha_composite_solid(self.simd, blend_buf, color, alphas); + } else { + let start_x = self.wide_coords.0 * WideTile::WIDTH + x as u16; + let start_y = self.wide_coords.1 * Tile::HEIGHT; + + T::blend( + self.simd, + blend_buf, + start_x, + start_y, + iter::repeat(T::Composite::from_color(self.simd, color)), + blend_mode, + alphas, + mask, + ); + } + } + Paint::Indexed(paint) => { + let color_buf = &mut self.paint_buf[x * TILE_HEIGHT_COMPONENTS..] + [..TILE_HEIGHT_COMPONENTS * width]; + + let encoded_paint = &encoded_paints[paint.index()]; + + let start_x = self.wide_coords.0 * WideTile::WIDTH + x as u16; + let start_y = self.wide_coords.1 * Tile::HEIGHT; + + // Make sure sampling happens at the center of the pixel. + let sampler_x = start_x as f64 + PIXEL_CENTER_OFFSET; + let sampler_y = start_y as f64 + PIXEL_CENTER_OFFSET; + + // We need to have this as a macro because closures cannot take generic arguments, and + // we would have to repeatedly provide all arguments if we made it a function. + macro_rules! fill_complex_paint { + ($may_have_transparency:expr, $filler:expr) => { + fill_complex_paint!($may_have_transparency, $filler, None::<&Tint>) + }; + ($may_have_transparency:expr, $filler:expr, $tint:expr) => { + if $may_have_transparency || alphas.is_some() { + T::apply_painter(self.simd, color_buf, $filler); + if let Some(t) = $tint { + T::apply_tint(self.simd, color_buf, t); + } + + if default_blend && mask.is_none() { + T::alpha_composite_buffer(self.simd, blend_buf, color_buf, alphas); + } else { + T::blend( + self.simd, + blend_buf, + start_x, + start_y, + color_buf + .chunks_exact(T::Composite::LENGTH) + .map(|s| T::Composite::from_slice(self.simd, s)), + blend_mode, + alphas, + mask, + ); + } + } else { + // Similarly to solid colors we can just override the previous values + // if all colors in the gradient are fully opaque. + T::apply_painter(self.simd, blend_buf, $filler); + if let Some(t) = $tint { + T::apply_tint(self.simd, blend_buf, t); + } + } + }; + } + + match encoded_paint { + EncodedPaint::BlurredRoundedRect(b) => { + fill_complex_paint!( + true, + T::blurred_rounded_rectangle_painter( + self.simd, b, sampler_x, sampler_y + ) + ); + } + EncodedPaint::Gradient(g) => { + // Note that we are calculating the t values first, store them in a separate + // buffer and then pass that buffer to the iterator instead of calculating + // the t values on the fly in the iterator. The latter would be faster, but + // it would probably increase code size a lot, because the functions for + // position calculation need to be inlined for good performance. + let f32_buf = &mut self.f32_buf[..width * Tile::HEIGHT as usize]; + + match &g.kind { + EncodedKind::Linear(l) => { + calculate_t_vals( + self.simd, + SimdLinearKind::new(self.simd, *l), + f32_buf, + g, + sampler_x, + sampler_y, + ); + + fill_complex_paint!( + g.may_have_transparency, + T::gradient_painter(self.simd, g, f32_buf) + ); + } + EncodedKind::Sweep(s) => { + calculate_t_vals( + self.simd, + SimdSweepKind::new(self.simd, s), + f32_buf, + g, + sampler_x, + sampler_y, + ); + + fill_complex_paint!( + g.may_have_transparency, + T::gradient_painter(self.simd, g, f32_buf) + ); + } + EncodedKind::Radial(r) => { + calculate_t_vals( + self.simd, + SimdRadialKind::new(self.simd, r), + f32_buf, + g, + sampler_x, + sampler_y, + ); + + if r.has_undefined() { + fill_complex_paint!( + g.may_have_transparency, + T::gradient_painter_with_undefined(self.simd, g, f32_buf) + ); + } else { + fill_complex_paint!( + g.may_have_transparency, + T::gradient_painter(self.simd, g, f32_buf) + ); + } + } + } + } + EncodedPaint::Image(i) => { + let pixmap = match &i.source { + ImageSource::Pixmap(p) => p.clone(), + ImageSource::OpaqueId { id, .. } => image_resolver + .resolve(*id) + .unwrap_or_else(|| panic!("Image {:?} not found in registry", id)), + }; + + let tint = i.tint.as_ref(); + + match (i.has_skew(), i.nearest_neighbor()) { + (false, false) => { + // Axis-aligned with filtering - use optimized plain painters + if i.sampler.quality == ImageQuality::Medium { + fill_complex_paint!( + i.may_have_transparency, + T::plain_medium_quality_image_painter( + self.simd, i, &pixmap, sampler_x, sampler_y + ), + tint + ); + } else { + fill_complex_paint!( + i.may_have_transparency, + T::high_quality_image_painter( + self.simd, i, &pixmap, sampler_x, sampler_y + ), + tint + ); + } + } + (true, false) => { + // Skewed with filtering - use generic filtered painters + if i.sampler.quality == ImageQuality::Medium { + fill_complex_paint!( + i.may_have_transparency, + T::medium_quality_image_painter( + self.simd, i, &pixmap, sampler_x, sampler_y + ), + tint + ); + } else { + fill_complex_paint!( + i.may_have_transparency, + T::high_quality_image_painter( + self.simd, i, &pixmap, sampler_x, sampler_y + ), + tint + ); + } + } + (false, true) => { + fill_complex_paint!( + i.may_have_transparency, + T::plain_nn_image_painter( + self.simd, i, &pixmap, sampler_x, sampler_y + ), + tint + ); + } + (true, true) => { + fill_complex_paint!( + i.may_have_transparency, + T::nn_image_painter( + self.simd, i, &pixmap, sampler_x, sampler_y + ), + tint + ); + } + } + } + EncodedPaint::ExternalTexture(_) => { + unimplemented!("External textures are not supported by `vello_cpu`") + } + } + } + } + } + + /// Blend the top blend buffer into the buffer below it. + /// + /// This pops the top buffer from the blend stack and composites it onto the + /// buffer below using the specified blend mode. This is the core operation for + /// layer composition. + pub(crate) fn blend(&mut self, blend_mode: BlendMode) { + let (source_buffer, rest) = self.blend_buf.split_last_mut().unwrap(); + let target_buffer = rest.last_mut().unwrap(); + + if blend_mode == BlendMode::default() { + T::alpha_composite_buffer(self.simd, target_buffer, source_buffer, None); + } else { + T::blend( + self.simd, + target_buffer, + // `start_x` and `start_y` are only needed to sample the correct position + // of a mask, so we can just pass dummy values here. + 0, + 0, + source_buffer + .chunks_exact(T::Composite::LENGTH) + .map(|s| T::Composite::from_slice(self.simd, s)), + blend_mode, + None, + None, + ); + } + } + + /// Apply a clipping mask from the top buffer to the buffer below. + /// + /// Uses the top buffer's alpha channel as a mask, multiplying it with the buffer + /// below. This implements clipping by masking out pixels outside the clip region. + fn clip(&mut self, x: usize, width: usize, alphas: Option<&[u8]>) { + let (source_buffer, rest) = self.blend_buf.split_last_mut().unwrap(); + let target_buffer = rest.last_mut().unwrap(); + + let source_buffer = + &mut source_buffer[x * TILE_HEIGHT_COMPONENTS..][..TILE_HEIGHT_COMPONENTS * width]; + let target_buffer = + &mut target_buffer[x * TILE_HEIGHT_COMPONENTS..][..TILE_HEIGHT_COMPONENTS * width]; + + T::alpha_composite_buffer(self.simd, target_buffer, source_buffer, alphas); + } +} + +/// A trait for objects that can render pixel data into buffers. +/// +/// Painters abstract over different content sources (gradients, images, etc.) and can +/// generate pixel data in either u8 or f32 format. Implementations should provide at least +/// one of these methods; the other can delegate through conversion. +/// +/// Note: Some painters may only efficiently support one numeric type. The implementation +/// may convert between types as needed. +pub trait Painter { + /// Paint pixel data into a u8 buffer (values in 0-255 range). + fn paint_u8(&mut self, buf: &mut [u8]); + + /// Paint pixel data into an f32 buffer (values in 0.0-1.0 range). + fn paint_f32(&mut self, buf: &mut [f32]); +} + +/// Extension trait for creating position vectors for gradient and image sampling. +/// +/// This trait provides a method to generate SIMD vectors of positions that advance +/// correctly across a tile. It's used by painters to compute per-pixel coordinates +/// for sampling operations. +pub trait PosExt { + /// Create a position vector that advances appropriately across a tile. + /// + /// Given a starting position and per-pixel advances in x and y directions, + /// generates a SIMD vector with the correct position for each element. + fn splat_pos(simd: S, pos: f32, x_advance: f32, y_advance: f32) -> Self; +} + +impl PosExt for f32x4 { + #[inline(always)] + fn splat_pos(simd: S, pos: f32, _: f32, y_advance: f32) -> Self { + let columns: [f32; Tile::HEIGHT as usize] = [0.0, 1.0, 2.0, 3.0]; + let column_mask: Self = columns.simd_into(simd); + + column_mask.mul_add(Self::splat(simd, y_advance), Self::splat(simd, pos)) + } +} + +impl PosExt for f32x8 { + #[inline(always)] + fn splat_pos(simd: S, pos: f32, x_advance: f32, y_advance: f32) -> Self { + simd.combine_f32x4( + f32x4::splat_pos(simd, pos, x_advance, y_advance), + f32x4::splat_pos(simd, pos + x_advance, x_advance, y_advance), + ) + } +} + +/// Intermediate shader result with color channels stored separately for efficient processing. +/// +/// This structure holds 8 pixels worth of data in planar format (separate R, G, B, A vectors). +/// The planar layout is more efficient for certain SIMD operations before final interleaving. +pub(crate) struct ShaderResultF32 { + /// Red channel values for 8 pixels. + pub(crate) r: f32x8, + /// Green channel values for 8 pixels. + pub(crate) g: f32x8, + /// Blue channel values for 8 pixels. + pub(crate) b: f32x8, + /// Alpha channel values for 8 pixels. + pub(crate) a: f32x8, +} + +impl ShaderResultF32 { + /// Convert from planar format to interleaved RGBA format. + /// + /// Returns two f32x16 vectors containing 8 pixels (4 RGBA components each) + /// with channels interleaved in the standard RGBA order. + #[inline(always)] + pub(crate) fn get(&self) -> (f32x16, f32x16) { + let (r_1, r_2) = self.r.simd.split_f32x8(self.r); + let (g_1, g_2) = self.g.simd.split_f32x8(self.g); + let (b_1, b_2) = self.b.simd.split_f32x8(self.b); + let (a_1, a_2) = self.a.simd.split_f32x8(self.a); + + let first = self.r.simd.combine_f32x8( + self.r.simd.combine_f32x4(r_1, g_1), + self.r.simd.combine_f32x4(b_1, a_1), + ); + + let second = self.r.simd.combine_f32x8( + self.r.simd.combine_f32x4(r_2, g_2), + self.r.simd.combine_f32x4(b_2, a_2), + ); + + (first, second) + } +} + +mod macros { + /// Implements the `Painter` trait for an iterator that produces f32x16 SIMD vectors. + /// + /// This macro generates both `paint_u8` and `paint_f32` methods, converting between + /// formats as needed. Used for painters that work natively with high-precision f32 data. + macro_rules! f32x16_painter { + ($($type_path:tt)+) => { + impl crate::fine::Painter for $($type_path)+ { + fn paint_u8(&mut self, buf: &mut [u8]) { + use vello_common::fearless_simd::*; + use crate::fine::NumericVec; + + self.simd.vectorize(#[inline(always)] || { + for chunk in buf.chunks_exact_mut(16) { + let next = self.next().unwrap(); + let converted = u8x16::::from_f32(next.simd, next); + converted.store_slice(chunk); + } + }) + } + + fn paint_f32(&mut self, buf: &mut [f32]) { + self.simd.vectorize(#[inline(always)] || { + for chunk in buf.chunks_exact_mut(16) { + let next = self.next().unwrap(); + next.store_slice(chunk); + } + }) + } + } + }; + } + + /// Implements the `Painter` trait for an iterator that produces u8x16 SIMD vectors. + /// + /// This macro generates both `paint_u8` and `paint_f32` methods, converting between + /// formats as needed. Used for painters that work natively with low-precision u8 data. + macro_rules! u8x16_painter { + ($($type_path:tt)+) => { + impl crate::fine::Painter for $($type_path)+ { + fn paint_u8(&mut self, buf: &mut [u8]) { + self.simd.vectorize(#[inline(always)] || { + for chunk in buf.chunks_exact_mut(16) { + let next = self.next().unwrap(); + next.store_slice(chunk); + } + }) + } + + fn paint_f32(&mut self, buf: &mut [f32]) { + use vello_common::fearless_simd::*; + use crate::fine::NumericVec; + + self.simd.vectorize(#[inline(always)] || { + for chunk in buf.chunks_exact_mut(16) { + let next = self.next().unwrap(); + let converted = f32x16::::from_u8(next.simd, next); + converted.store_slice(chunk); + } + }) + } + } + }; + } + + pub(crate) use f32x16_painter; + pub(crate) use u8x16_painter; +} diff --git a/vendor/vello_cpu/src/layer_manager.rs b/vendor/vello_cpu/src/layer_manager.rs new file mode 100644 index 0000000000..62c1a99165 --- /dev/null +++ b/vendor/vello_cpu/src/layer_manager.rs @@ -0,0 +1,154 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Layer management for filter effects rendering. +//! +//! This module provides infrastructure for managing persistent layer buffers +//! that can be rendered to independently and then composited together. This is +//! necessary for spatial filter effects that require access to a fully-rendered +//! layer (e.g., Gaussian blur). +//! +//! Layers are stored as Pixmap instances (row-major, u8 RGBA format), which +//! allows efficient filter operations without layout conversion overhead. +//! Each layer maintains its own bounding box in wide tile coordinates, allowing +//! efficient memory usage for layers that don't span the entire render target. + +use crate::region::Region; +use hashbrown::HashMap; +use vello_common::coarse::WideTilesBbox; +use vello_common::pixmap::Pixmap; + +/// Manages persistent layer storage for filter effects. +/// +/// Each layer is allocated as a `Pixmap` with row-major RGBA8 format. +/// This eliminates conversion overhead for filter operations. +#[derive(Debug)] +pub struct LayerManager { + /// Map of layer ID to (Pixmap, wtile bounding box). + /// The Pixmap contains the layer's pixel data, and the Bbox defines which + /// wide tiles this layer occupies (in wide tile coordinates). + layers: HashMap, + /// Next available layer ID for automatic allocation. + next_id: u32, + /// Reusable scratch buffer for filter operations that need temporary storage. + /// Examples include separable convolution passes (e.g., Gaussian blur) or + /// intermediate compositing results. This buffer is lazily allocated and + /// automatically resized as needed to avoid repeated allocations. + scratch_buffer: Option, +} + +impl Default for LayerManager { + /// Creates a new, empty layer manager with no allocated layers. + /// + /// Layer IDs start from 1 (ID 0 is reserved for internal use). + fn default() -> Self { + Self { + layers: HashMap::new(), + next_id: 1, + scratch_buffer: None, + } + } +} + +impl LayerManager { + /// Create a new, empty layer manager. + /// + /// Layers can be registered on-demand using [`register_layer`](Self::register_layer). + pub fn new() -> Self { + Self::default() + } + + /// Register a layer with a specific ID, wide tile bounding box, and pixel data. + /// + /// The provided `Pixmap` should already contain the layer's pixel data (e.g., the + /// result of rendering a layer or applying a filter effect). + /// + /// If a layer with this ID already exists, this method does nothing (no replacement). + /// + /// # Parameters + /// - `layer_id`: A unique identifier for this layer. Layer ID 0 is reserved for internal use. + /// - `wtile_bbox`: The bounding box of the layer in wide tile coordinates. This defines + /// the region of the layer that contains valid pixel data, enabling efficient memory + /// usage for layers that don't span the entire render target. + /// - `pixmap`: The pixel data for this layer in row-major RGBA8 format. + pub fn register_layer(&mut self, layer_id: u32, wtile_bbox: WideTilesBbox, pixmap: Pixmap) { + self.layers.insert(layer_id, (pixmap, wtile_bbox)); + + if layer_id >= self.next_id { + self.next_id = layer_id + 1; + } + } + + /// Get a mutable `Region` view into a specific wide tile within a layer. + /// + /// This extracts a tile-sized region from the layer's Pixmap, allowing + /// rendering or compositing operations on individual tiles without copying + /// pixel data. The returned Region provides a mutable view into the underlying + /// layer buffer. + /// + /// Returns [`None`] if: + /// - The layer doesn't exist + /// - The tile position is outside the layer's bounding box + /// - The tile extraction fails (e.g., invalid coordinates) + /// + /// # Parameters + /// - `layer_id`: The ID of the layer to access + /// - `tile_x`: The x-coordinate of the wide tile in global wide tile coordinates + /// - `tile_y`: The y-coordinate of the wide tile in global wide tile coordinates + /// + /// # Coordinate Systems + /// The input coordinates (`tile_x`, `tile_y`) are in *global* wide tile space, + /// but they are automatically converted to *local* coordinates relative to the + /// layer's bounding box before extracting the region. + pub fn layer_tile_region_mut( + &mut self, + layer_id: u32, + tile_x: u16, + tile_y: u16, + ) -> Option> { + let (pixmap, bbox) = self.layers.get_mut(&layer_id)?; + + // Ensure the requested tile is within the layer's allocated bounds + if !bbox.contains(tile_x, tile_y) { + return None; + } + + // Convert global tile coordinates to layer-local (bbox-relative) coordinates + let local_x = tile_x - bbox.x0(); + let local_y = tile_y - bbox.y0(); + + // Extract a mutable Region view of the tile from the underlying pixmap + Region::from_pixmap_tile(pixmap, local_x, local_y) + } + + /// Get or create a scratch buffer of at least the requested dimensions. + /// + /// This buffer is reused across filter operations to minimize allocations. + /// If the existing buffer is large enough, it's reused; otherwise, a new + /// (larger) buffer is allocated. + /// + /// # Parameters + /// - `width`: Minimum width in pixels + /// - `height`: Minimum height in pixels + /// + /// # Returns + /// A mutable reference to a `Pixmap` of at least `width × height` pixels. + /// The actual buffer may be larger than requested if it was previously allocated + /// with larger dimensions. + pub fn get_scratch_buffer(&mut self, width: u16, height: u16) -> &mut Pixmap { + match &mut self.scratch_buffer { + None => { + // No buffer exists yet, allocate a new one + self.scratch_buffer = Some(Pixmap::new(width, height)); + } + Some(buf) if buf.width() < width || buf.height() < height => { + // Existing buffer is too small, resize it + buf.resize(width, height); + } + // Buffer is already large enough, reuse it without reallocation + Some(_) => {} + } + + self.scratch_buffer.as_mut().unwrap() + } +} diff --git a/vendor/vello_cpu/src/lib.rs b/vendor/vello_cpu/src/lib.rs new file mode 100644 index 0000000000..11299b2912 --- /dev/null +++ b/vendor/vello_cpu/src/lib.rs @@ -0,0 +1,186 @@ +// 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_cpu + +//! Vello CPU is a 2D graphics rendering engine written in Rust, for devices with no or underpowered GPUs. +//! +//! 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. +//! +//! # Usage +//! +//! To use Vello CPU, you need to: +//! +//! - Create a [`RenderContext`][], a 2D drawing context for a fixed-size target area. +//! - For each object in your scene: +//! - Set how the object will be painted, using [`set_paint`][RenderContext::set_paint]. +//! - Set the shape to be drawn for that object, using methods like [`fill_path`][RenderContext::fill_path], +//! [`stroke_path`][RenderContext::stroke_path], or [`glyph_run`][RenderContext::glyph_run]. +//! - Render it to an image using [`RenderContext::render_to_pixmap`][]. +//! +//! ```rust +//! use vello_cpu::{RenderContext, Resources, Pixmap, RenderMode}; +//! use vello_cpu::{color::{palette::css, PremulRgba8}, kurbo::Rect}; +//! let width = 10; +//! let height = 5; +//! let mut context = RenderContext::new(width, height); +//! let mut resources = Resources::new(); +//! context.set_paint(css::MAGENTA); +//! context.fill_rect(&Rect::from_points((3., 1.), (7., 4.))); +//! +//! let mut target = Pixmap::new(width, height); +//! // While calling `flush` is only strictly necessary if you are rendering using +//! // multiple threads, it is recommended to always do this. +//! context.flush(); +//! context.render_to_pixmap(&mut resources, &mut target); +//! +//! let expected_render = b"\ +//! 0000000000\ +//! 0001111000\ +//! 0001111000\ +//! 0001111000\ +//! 0000000000"; +//! let magenta = css::MAGENTA.premultiply().to_rgba8(); +//! let transparent = PremulRgba8 {r: 0, g: 0, b: 0, a: 0}; +//! let mut result = Vec::new(); +//! for pixel in target.data() { +//! if *pixel == magenta { +//! result.push(b'1'); +//! } else if *pixel == transparent { +//! result.push(b'0'); +//! } else { +//! panic!("Got unexpected pixel value {pixel:?}"); +//! } +//! } +//! assert_eq!(&result, expected_render); +//! ``` +//! +//! Feel free to take a look at some further +//! [examples](https://github.com/linebender/vello/tree/main/sparse_strips/vello_cpu/examples) +//! to better understand how to interact with Vello CPU's API, +//! +//! # 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`]s from PNG images. +//! Also required for rendering glyphs with an embedded PNG. Implies `std`. +//! - `multithreading`: Enable multi-threaded rendering. Implies `std`. +//! - `text` (enabled by default): Enables glyph rendering ([`glyph_run`][RenderContext::glyph_run]). +//! - `u8_pipeline` (enabled by default): Enable the u8 pipeline, for speed focused rendering using u8 math. +//! The `u8` pipeline will be used for [`OptimizeSpeed`][RenderMode::OptimizeSpeed], if both pipelines are enabled. +//! If you're using Vello CPU for application rendering, you should prefer this pipeline. +//! - `f32_pipeline`: Enable the `f32` pipeline, which is slower but has more accurate +//! results. This is espectially useful for rendering test snapshots. +//! The `f32` pipeline will be used for [`OptimizeQuality`][RenderMode::OptimizeQuality], if both pipelines are enabled. +//! +//! At least one of `std` and `libm` is required; `std` overrides `libm`. +//! At least one of `u8_pipeline` and `f32_pipeline` must be enabled. +//! You might choose to disable one of these pipelines if your application +//! won't use it, so as to reduce binary size. +//! +//! # Caveats +//! +//! Overall, Vello CPU is already very feature-rich and should be ready for +//! production use cases. The main caveat at the moment is that the API is +//! still likely to change and not stable yet. For example, we have +//! known plans to change the API around how image resources are used. +//! +//! Additionally, there are certain APIs that are still very much experimental, +//! including for example support for filters. This will be reflected in the +//! documentation of those APIs. +//! +//! Another caveat is that multi-threading with large thread counts +//! (more than 4) might give diminishing returns, especially when +//! making heavy use of layers and clip paths. +//! +//! # Performance +//! +//! Performance benchmarks can be found [here](https://laurenzv.github.io/vello_chart/), +//! As can be seen, Vello CPU achieves compelling performance on both, +//! aarch64 and x86 platforms. We also have SIMD optimizations for WASM SIMD, +//! meaning that you can expect good performance there as well. +//! +//! # Implementation +//! +//! If you want to gain a better understanding of Vello CPU and the +//! sparse strips paradigm, you can take a look at the [accompanying +//! master's thesis](https://ethz.ch/content/dam/ethz/special-interest/infk/inst-pls/plf-dam/documents/StudentProjects/MasterTheses/2025-Laurenz-Thesis.pdf) +//! that was written on the topic. Note that parts of the descriptions might +//! become outdated as the implementation changes, but it should give a good +//! overview nevertheless. +//! +//! +//! [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 cast u16s to u8 in various places where we know for sure that it's < 256" +)] +#![no_std] + +extern crate alloc; +extern crate core; +// Unused in release mode because it's only used directly in the `text_debug` module +// (or transitively in vello_common). +#[cfg(feature = "png")] +use png as _; +#[cfg(feature = "std")] +extern crate std; + +#[cfg(all(not(feature = "u8_pipeline"), not(feature = "f32_pipeline")))] +compile_error!("vello_cpu must have at least one of the u8 or f32 pipelines enabled"); + +mod render; + +mod dispatch; +mod filter; +#[cfg(feature = "text")] +mod text; +#[cfg(all(feature = "text", feature = "std", debug_assertions))] +mod text_debug; +mod util; + +#[doc(hidden)] +pub mod fine; +#[doc(hidden)] +pub mod layer_manager; +#[doc(hidden)] +pub mod region; + +pub use render::{RenderContext, RenderSettings, Resources}; +// Note: The first one is not something that should be +// exposed, but is currently needed by vello_sparse_tests. +#[cfg(feature = "text")] +pub use glifo::Glyph; +#[cfg(feature = "text")] +pub use text::{CpuGlyphRunBackend, GlyphRunBuilder}; +pub use vello_common::fearless_simd::Level; +pub use vello_common::mask::Mask; +pub use vello_common::paint::{Image, ImageSource, Paint, PaintType}; +pub use vello_common::pixmap::Pixmap; +pub use vello_common::{color, kurbo, peniko}; + +/// The selected rendering mode. +/// For using [`RenderMode::OptimizeQuality`] you also need to enable `f32_pipeline` feature. +#[derive(Copy, Clone, Debug, Default)] +pub enum RenderMode { + /// Optimize speed (by performing calculations with u8/16). + #[default] + OptimizeSpeed, + /// Optimize quality (by performing calculations with f32). + OptimizeQuality, +} diff --git a/vendor/vello_cpu/src/region.rs b/vendor/vello_cpu/src/region.rs new file mode 100644 index 0000000000..00e78062ad --- /dev/null +++ b/vendor/vello_cpu/src/region.rs @@ -0,0 +1,243 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Splitting a single mutable buffer into regions that can be accessed concurrently. + +use crate::fine::COLOR_COMPONENTS; +use alloc::vec::Vec; +use vello_common::coarse::WideTile; +use vello_common::pixmap::Pixmap; +use vello_common::tile::Tile; + +#[derive(Debug)] +pub struct Regions<'a> { + regions: Vec>, +} + +impl<'a> Regions<'a> { + /// Creates regions from a buffer where the buffer dimensions match the render dimensions. + pub fn new(width: u16, height: u16, buffer: &'a mut [u8]) -> Self { + Self::new_at_offset(width, height, 0, 0, width, height, buffer) + } + + /// Creates regions from a buffer at a specific offset. + /// + /// This is used for rendering to a sub-region of a larger buffer. The regions + /// cover the area of size (`width` × `height`) placed at pixel offset + /// (`dst_x`, `dst_y`) in the destination buffer. + /// + /// # Arguments + /// * `width` - Width of the content being rendered + /// * `height` - Height of the content being rendered + /// * `dst_x` - X offset in the destination buffer + /// * `dst_y` - Y offset in the destination buffer + /// * `dst_buffer_width` - Total width of the destination buffer + /// * `dst_buffer_height` - Total height of the destination buffer + /// * `buffer` - The destination buffer (RGBA, 4 bytes per pixel) + pub fn new_at_offset( + width: u16, + height: u16, + dst_x: u16, + dst_y: u16, + dst_buffer_width: u16, + dst_buffer_height: u16, + mut buffer: &'a mut [u8], + ) -> Self { + // Calculate effective render area (clamped to destination bounds) + let effective_width = width.min(dst_buffer_width.saturating_sub(dst_x)) as usize; + let effective_height = height.min(dst_buffer_height.saturating_sub(dst_y)) as usize; + + if effective_width == 0 || effective_height == 0 { + return Self { + regions: Vec::new(), + }; + } + + let width_regions = effective_width.div_ceil(WideTile::WIDTH as usize); + let height_regions = effective_height.div_ceil(Tile::HEIGHT as usize); + + let mut regions = Vec::with_capacity(width_regions * height_regions); + + let row_stride = dst_buffer_width as usize * COLOR_COMPONENTS; + let render_row_bytes = effective_width * COLOR_COMPONENTS; + + // Calculate starting offset in the buffer + let start_offset = (dst_y as usize * row_stride) + (dst_x as usize * COLOR_COMPONENTS); + buffer = &mut buffer[start_offset..]; + + let mut next_lines: [&'a mut [u8]; Tile::HEIGHT as usize] = + [&mut [], &mut [], &mut [], &mut []]; + + for y in 0..height_regions { + let base_y = y * Tile::HEIGHT as usize; + let region_height = (Tile::HEIGHT as usize).min(effective_height - base_y); + + // Extract Tile::HEIGHT rows from the buffer + // Each row is at row_stride intervals + for line in next_lines.iter_mut().take(region_height) { + // Take only the render area portion of this row + let (render_portion, rest) = buffer.split_at_mut(render_row_bytes); + *line = render_portion; + // Skip the remainder of this buffer row to get to the next row. + // On the last row (when clipped to pixmap edge), there may be less + // data remaining, so cap the skip to what's available. + let skip = (row_stride - render_row_bytes).min(rest.len()); + buffer = &mut rest[skip..]; + } + + // Split each row horizontally into tile-width chunks + for x in 0..width_regions { + let mut areas: [&mut [u8]; Tile::HEIGHT as usize] = + [&mut [], &mut [], &mut [], &mut []]; + + let base_x = x * WideTile::WIDTH as usize; + let region_width_bytes = + ((WideTile::WIDTH as usize).min(effective_width - base_x)) * COLOR_COMPONENTS; + + for h in 0..region_height { + let next = core::mem::take(&mut next_lines[h]); + let (head, tail) = next.split_at_mut(region_width_bytes); + areas[h] = head; + next_lines[h] = tail; + } + + regions.push(Region::new( + areas, + u16::try_from(x).unwrap(), + u16::try_from(y).unwrap(), + (region_width_bytes / COLOR_COMPONENTS) as u16, + region_height as u16, + )); + } + } + + Self { regions } + } + + /// Apply the given function to each region. The functions will be applied + /// in parallel in the current threadpool. + #[cfg(feature = "multithreading")] + pub fn update_regions_par(&mut self, func: impl Fn(&mut Region<'_>) + Send + Sync) { + use rayon::iter::ParallelIterator; + use rayon::prelude::IntoParallelRefMutIterator; + + self.regions.par_iter_mut().for_each(func); + } + + /// Apply the given function to each region. + pub fn update_regions(&mut self, func: impl FnMut(&mut Region<'_>)) { + self.regions.iter_mut().for_each(func); + } +} + +/// A rectangular region containing the pixels from one wide tile. +/// +/// For wide tiles at the right/bottom edge, it might contain less pixels +/// than the actual wide tile, if the pixmap width/height isn't a multiple of the +/// tile width/height. +#[derive(Default, Debug)] +pub struct Region<'a> { + /// The x coordinate of the wide tile this region covers. + pub(crate) x: u16, + /// The y coordinate of the wide tile this region covers. + pub(crate) y: u16, + pub width: u16, + pub height: u16, + areas: [&'a mut [u8]; Tile::HEIGHT as usize], +} + +impl<'a> Region<'a> { + pub(crate) fn new( + areas: [&'a mut [u8]; Tile::HEIGHT as usize], + x: u16, + y: u16, + width: u16, + height: u16, + ) -> Self { + Self { + areas, + x, + y, + width, + height, + } + } + + /// Extracts a `Region` from a pixmap at the specified tile coordinates. + /// + /// The region corresponds to a wide tile area (`WideTile::WIDTH` × `Tile::HEIGHT` pixels), + /// starting at pixel coordinates `(tile_x * WideTile::WIDTH, tile_y * Tile::HEIGHT)`. + /// Regions at the right or bottom edges may be smaller if they extend beyond the pixmap bounds. + /// + /// Returns `None` if the tile coordinates are completely outside the pixmap bounds. + /// + /// # Arguments + /// * `pixmap` - The pixmap to extract from + /// * `tile_x` - Tile column index (in tile units, not pixels) + /// * `tile_y` - Tile row index (in tile units, not pixels) + pub(crate) fn from_pixmap_tile( + pixmap: &'a mut Pixmap, + tile_x: u16, + tile_y: u16, + ) -> Option { + let pixmap_width = pixmap.width(); + let pixmap_height = pixmap.height(); + + // Calculate pixel coordinates for this tile + let base_x = tile_x * WideTile::WIDTH; + let base_y = tile_y * Tile::HEIGHT; + + // Check bounds + if base_x >= pixmap_width || base_y >= pixmap_height { + return None; + } + + // Calculate actual region dimensions (might be smaller at edges) + let region_width = WideTile::WIDTH.min(pixmap_width - base_x); + let region_height = Tile::HEIGHT.min(pixmap_height - base_y); + + // Get mutable access to the pixmap's buffer + let buffer = pixmap.data_as_u8_slice_mut(); + + // Split buffer into row slices for this tile + let row_stride = pixmap_width as usize * COLOR_COMPONENTS; + let start_offset = (base_y as usize * row_stride) + (base_x as usize * COLOR_COMPONENTS); + let region_width_bytes = region_width as usize * COLOR_COMPONENTS; + + // Skip to the start of our tile region + let tile_buffer = &mut buffer[start_offset..]; + + // Extract individual row slices using safe split operations + let mut areas: [&mut [u8]; Tile::HEIGHT as usize] = [&mut [], &mut [], &mut [], &mut []]; + + // Use split_at_mut to safely extract each row + let mut remaining = tile_buffer; + for (i, area) in areas.iter_mut().take(region_height as usize).enumerate() { + if i > 0 { + // Skip rows we've already processed (advance by stride - region_width_bytes) + let skip = row_stride - region_width_bytes; + remaining = &mut remaining[skip..]; + } + + let (row, rest) = remaining.split_at_mut(region_width_bytes.min(remaining.len())); + *area = row; + remaining = rest; + } + + Some(Self::new( + areas, + tile_x, + tile_y, + region_width, + region_height, + )) + } + + pub(crate) fn row_mut(&mut self, y: u16) -> &mut [u8] { + self.areas[usize::from(y)] + } + + pub fn areas(&mut self) -> &mut [&'a mut [u8]; Tile::HEIGHT as usize] { + &mut self.areas + } +} diff --git a/vendor/vello_cpu/src/render.rs b/vendor/vello_cpu/src/render.rs new file mode 100644 index 0000000000..fe4d803108 --- /dev/null +++ b/vendor/vello_cpu/src/render.rs @@ -0,0 +1,917 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Basic render operations. + +use crate::RenderMode; +use crate::dispatch::Dispatcher; +#[cfg(feature = "text")] +use crate::text::{GlyphAtlasResources, GlyphRunBuilder}; +#[cfg(feature = "text")] +use glifo::GlyphPrepCache; + +#[cfg(feature = "multithreading")] +use crate::dispatch::multi_threaded::MultiThreadedDispatcher; +use crate::dispatch::single_threaded::SingleThreadedDispatcher; +use crate::kurbo::{PathEl, Point}; +use alloc::boxed::Box; +use alloc::sync::Arc; +use alloc::vec; +use alloc::vec::Vec; +use hashbrown::HashMap; +use vello_common::blurred_rounded_rect::BlurredRoundedRectangle; +use vello_common::encode::{EncodeExt, EncodedPaint}; +use vello_common::fearless_simd::Level; +use vello_common::filter_effects::Filter; +use vello_common::kurbo::{Affine, BezPath, Rect, Stroke}; +use vello_common::mask::Mask; +use vello_common::paint::{ImageId, ImageResolver, Paint, PaintType, Tint}; +use vello_common::peniko::color::palette::css::BLACK; +use vello_common::peniko::{BlendMode, Fill}; +use vello_common::pixmap::Pixmap; +use vello_common::render_state::RenderState; +use vello_common::util::is_axis_aligned; + +#[cfg(feature = "text")] +pub(crate) const DEFAULT_GLYPH_ATLAS_SIZE: u16 = 4096; +// Why do we need this? The reason is that the way uploaded images work in Vello Hybrid +// is different from how they work in Vello CPU. +// +// In Vello Hybrid, all images, regardless of whether they are user-uploaded +// images or cached glyphs, are stored in an image atlas at a certain location. An image ID then +// uniquely resolves to an atlas page index + a location on that page. Whenever we want to +// cache a new glyph, we simply allocate a location in the image atlas and then return the image +// ID associated with that location. +// +// On Vello CPU, it works differently: An image ID is associated with a complete pixmap. +// If a user uploads an image, instead of blitting it into a bigger image atlas, we just +// store the user-provided pixmap and associate an image ID with the whole pixmap. However, +// for glyph caching to work we need the same semantics as in Vello Hybrid. Therefore, we +// use a marker to determine whether an image ID refers to a normal uploaded image or a cached +// glyph and apply special handling based on that. +// +// All IDs < than this value are reserved for normal images, all IDs >= this value are +// reserved for atlas pages. +pub(crate) const ATLAS_IMAGE_ID_BASE: u32 = u32::MAX / 2; + +/// Persistent resources required by Vello CPU for rendering. +#[derive(Debug, Default)] +pub struct Resources { + pub(crate) image_registry: ImageRegistry, + #[cfg(feature = "text")] + pub(crate) glyph_prep_cache: GlyphPrepCache, + // Will be initialized lazily on first use. + #[cfg(feature = "text")] + pub(crate) glyph_resources: Option, +} + +impl Resources { + /// Create a new set of renderer resources. + pub fn new() -> Self { + Self::default() + } + + pub(crate) fn before_render(&mut self) { + #[cfg(feature = "text")] + self.prepare_glyph_cache(); + } + + pub(crate) fn after_render(&mut self) { + #[cfg(feature = "text")] + self.maintain_glyph_cache(); + } +} + +/// A render context for CPU-based 2D graphics rendering. +/// +/// This is the main entry point for drawing operations. It maintains the current +/// rendering state (transforms, paint, stroke, etc.) and dispatches drawing commands +/// to the underlying rasterization engine. +#[derive(Debug)] +pub struct RenderContext { + /// Width of the render target in pixels. + pub(crate) width: u16, + /// Height of the render target in pixels. + pub(crate) height: u16, + /// The current rendering state. + pub(crate) state: RenderState, + /// The current mask in place. + pub(crate) mask: Option, + /// Temporary path buffer to avoid repeated allocations. + pub(crate) temp_path: BezPath, + /// Optional threshold for aliasing. + pub(crate) aliasing_threshold: Option, + pub(crate) encoded_paints: Vec, + pub(crate) filter: Option, + #[cfg_attr( + not(feature = "text"), + allow(dead_code, reason = "used when the `text` feature is enabled") + )] + pub(crate) render_settings: RenderSettings, + dispatcher: Box, +} + +/// Settings to apply to the render context. +#[derive(Copy, Clone, Debug)] +pub struct RenderSettings { + /// The SIMD level that should be used for rendering operations. + pub level: Level, + /// The number of worker threads that should be used for rendering. Only has an effect + /// if the `multithreading` feature is active. + pub num_threads: u16, + /// Whether to prioritize speed or quality when rendering. + /// + /// For most cases (especially for real-time rendering), it is highly recommended to set + /// this to `OptimizeSpeed`. If accuracy is a more significant concern (for example for visual + /// regression testing), then you can set this to `OptimizeQuality`. + /// + /// Currently, the only difference this makes is that when choosing `OptimizeSpeed`, rasterization + /// will happen using u8/u16, while `OptimizeQuality` will use a f32-based pipeline. + pub render_mode: RenderMode, +} + +impl Default for RenderSettings { + fn default() -> Self { + Self { + level: Level::try_detect().unwrap_or(Level::baseline()), + #[cfg(feature = "multithreading")] + num_threads: (std::thread::available_parallelism() + .unwrap() + .get() + .saturating_sub(1) as u16) + .min(8), + #[cfg(not(feature = "multithreading"))] + num_threads: 0, + render_mode: RenderMode::OptimizeSpeed, + } + } +} + +impl RenderContext { + /// Create a new render context with the given width and height in pixels. + pub fn new(width: u16, height: u16) -> Self { + Self::new_with(width, height, RenderSettings::default()) + } + + /// Create a new render context with specific settings. + pub fn new_with(width: u16, height: u16, settings: RenderSettings) -> Self { + #[cfg(feature = "multithreading")] + let dispatcher: Box = if settings.num_threads == 0 { + Box::new(SingleThreadedDispatcher::new(width, height, settings.level)) + } else { + Box::new(MultiThreadedDispatcher::new( + width, + height, + settings.num_threads, + settings.level, + )) + }; + + #[cfg(not(feature = "multithreading"))] + let dispatcher: Box = + { Box::new(SingleThreadedDispatcher::new(width, height, settings.level)) }; + + let encoded_paints = vec![]; + let temp_path = BezPath::new(); + let aliasing_threshold = None; + + Self { + width, + height, + dispatcher, + state: RenderState::default(), + aliasing_threshold, + render_settings: settings, + mask: None, + temp_path, + encoded_paints, + filter: None, + } + } + + fn encode_current_paint(&mut self) -> Paint { + match self.state.paint.clone() { + PaintType::Solid(s) => s.into(), + PaintType::Gradient(g) => { + // TODO: Add caching? + g.encode_into( + &mut self.encoded_paints, + self.state.transform * self.state.paint_transform, + None, + ) + } + PaintType::Image(i) => i.encode_into( + &mut self.encoded_paints, + self.state.transform * self.state.paint_transform, + self.state.tint, + ), + } + } + + /// Fill a path. + pub fn fill_path(&mut self, path: &BezPath) { + self.with_optional_filter(|ctx| { + let paint = ctx.encode_current_paint(); + ctx.dispatcher.fill_path( + path, + ctx.state.fill_rule, + ctx.state.transform, + paint, + ctx.state.blend_mode, + ctx.aliasing_threshold, + ctx.mask.clone(), + &ctx.encoded_paints, + ); + }); + } + + /// Stroke a path. + pub fn stroke_path(&mut self, path: &BezPath) { + self.with_optional_filter(|ctx| { + let paint = ctx.encode_current_paint(); + ctx.dispatcher.stroke_path( + path, + &ctx.state.stroke, + ctx.state.transform, + paint, + ctx.state.blend_mode, + ctx.aliasing_threshold, + ctx.mask.clone(), + &ctx.encoded_paints, + ); + }); + } + + /// Fill a rectangle. + pub fn fill_rect(&mut self, rect: &Rect) { + self.with_optional_filter(|ctx| { + let paint = ctx.encode_current_paint(); + + // Fast path: Use optimized rect filling if we have no skew in the path transform + // and anti-aliasing is enabled. + // TODO: Maybe also support no anti-aliasing in the fast path + if is_axis_aligned(&ctx.state.transform) && ctx.aliasing_threshold.is_none() { + // Transform the rect to screen coordinates. + let transformed_rect = ctx.state.transform.transform_rect_bbox(*rect); + ctx.dispatcher.fill_rect_fast( + &transformed_rect, + paint, + ctx.state.blend_mode, + ctx.mask.clone(), + &ctx.encoded_paints, + ); + } else { + // Fall back to path-based rendering for rotated/skewed transforms. + ctx.rect_to_temp_path(rect); + ctx.dispatcher.fill_path( + &ctx.temp_path, + ctx.state.fill_rule, + ctx.state.transform, + paint, + ctx.state.blend_mode, + ctx.aliasing_threshold, + ctx.mask.clone(), + &ctx.encoded_paints, + ); + } + }); + } + + /// Stroke a rectangle. + pub fn stroke_rect(&mut self, rect: &Rect) { + self.with_optional_filter(|ctx| { + ctx.rect_to_temp_path(rect); + let paint = ctx.encode_current_paint(); + ctx.dispatcher.stroke_path( + &ctx.temp_path, + &ctx.state.stroke, + ctx.state.transform, + paint, + ctx.state.blend_mode, + ctx.aliasing_threshold, + ctx.mask.clone(), + &ctx.encoded_paints, + ); + }); + } + + fn rect_to_temp_path(&mut self, rect: &Rect) { + self.temp_path.truncate(0); + self.temp_path + .push(PathEl::MoveTo(Point::new(rect.x0, rect.y0))); + self.temp_path + .push(PathEl::LineTo(Point::new(rect.x1, rect.y0))); + self.temp_path + .push(PathEl::LineTo(Point::new(rect.x1, rect.y1))); + self.temp_path + .push(PathEl::LineTo(Point::new(rect.x0, rect.y1))); + self.temp_path.push(PathEl::ClosePath); + } + + /// Fill a blurred rectangle with the given corner radius and standard deviation. + /// + /// Note that this only works properly if the current paint is set to a solid color. + /// If not, it will fall back to using black as the fill color. + pub fn fill_blurred_rounded_rect(&mut self, rect: &Rect, radius: f32, std_dev: f32) { + let rect = rect.abs(); + let color = match self.state.paint { + PaintType::Solid(s) => s, + // Fallback to black when attempting to blur a rectangle with an image/gradient paint + _ => BLACK, + }; + + let blurred_rect = BlurredRoundedRectangle { + rect, + color, + radius, + std_dev, + }; + + // The actual rectangle we paint needs to be larger so that the blurring effect + // is not cut off. + // The impulse response of a gaussian filter is infinite. + // For performance reason we cut off the filter at some extent where the response is close to zero. + let kernel_size = 2.5 * std_dev; + let inflated_rect = rect.inflate(f64::from(kernel_size), f64::from(kernel_size)); + let transform = self.state.transform * self.state.paint_transform; + + self.rect_to_temp_path(&inflated_rect); + + let paint = blurred_rect.encode_into(&mut self.encoded_paints, transform, None); + self.dispatcher.fill_path( + &self.temp_path, + Fill::NonZero, + self.state.transform, + paint, + self.state.blend_mode, + self.aliasing_threshold, + self.mask.clone(), + &self.encoded_paints, + ); + } + + /// Creates a builder for drawing a run of glyphs that have the same attributes. + #[cfg(feature = "text")] + pub fn glyph_run<'a>( + &'a mut self, + resources: &'a mut Resources, + font: &crate::peniko::FontData, + ) -> GlyphRunBuilder<'a> { + glifo::GlyphRunBuilder::new( + font.clone(), + self.state.transform, + crate::text::CpuGlyphRunBackend { + ctx: self, + resources, + atlas_cache_enabled: false, + }, + ) + } + + /// Push a new layer with the given properties. + /// + /// Note that the mask, if provided, needs to have the same size as the render context. Otherwise, + /// it will be ignored. In addition to that, the mask will not be affected by the current + /// transformation matrix in place. + pub fn push_layer( + &mut self, + clip_path: Option<&BezPath>, + blend_mode: Option, + opacity: Option, + mask: Option, + filter: Option, + ) { + let mask = mask.and_then(|m| { + if m.width() != self.width || m.height() != self.height { + None + } else { + Some(m) + } + }); + + let blend_mode = blend_mode.unwrap_or_default(); + let opacity = opacity.unwrap_or(1.0); + + self.dispatcher.push_layer( + clip_path, + self.state.fill_rule, + self.state.transform, + blend_mode, + opacity, + self.aliasing_threshold, + mask, + filter, + ); + } + + /// Push a new clip layer. + /// + /// See the explanation in the [clipping](https://github.com/linebender/vello/tree/main/sparse_strips/vello_cpu/examples) + /// example for how this method differs from `push_clip_path`. + pub fn push_clip_layer(&mut self, path: &BezPath) { + self.push_layer(Some(path), None, None, None, None); + } + + /// Push a new blend layer. + pub fn push_blend_layer(&mut self, blend_mode: BlendMode) { + self.push_layer(None, Some(blend_mode), None, None, None); + } + + /// Push a new opacity layer. + pub fn push_opacity_layer(&mut self, opacity: f32) { + self.push_layer(None, None, Some(opacity), None, None); + } + + /// Push a new mask layer. The mask needs to have the same dimensions as the + /// render context. The mask will not be affected by the current transform + /// in place. + /// + /// See the explanation in the [masking](https://github.com/linebender/vello/tree/main/sparse_strips/masking/examples) + /// example for how this method differs from `set_mask`. + pub fn push_mask_layer(&mut self, mask: Mask) { + self.push_layer(None, None, None, Some(mask), None); + } + + /// Push a filter layer that affects all subsequent drawing operations. + /// + /// WARNING: Note that filters are currently incomplete and experimental. In + /// particular, they will lead to a panic when used in combination with + /// multi-threaded rendering. + pub fn push_filter_layer(&mut self, filter: Filter) { + self.push_layer(None, None, None, None, Some(filter)); + } + + /// Set the aliasing threshold. + /// + /// If set to `None` (which is the recommended option in nearly all cases), + /// anti-aliasing will be applied. + /// + /// If instead set to some value, then a pixel will be fully painted if + /// the coverage is bigger than the threshold (between 0 and 255), otherwise + /// it will not be painted at all. + /// + /// Note that there is no performance benefit to disabling anti-aliasing and + /// this functionality is simply provided for compatibility. + pub fn set_aliasing_threshold(&mut self, aliasing_threshold: Option) { + self.aliasing_threshold = aliasing_threshold; + } + + /// Pop the last-pushed layer. + pub fn pop_layer(&mut self) { + self.dispatcher.pop_layer(); + } + + /// Set the current stroke. + pub fn set_stroke(&mut self, stroke: Stroke) { + self.state.stroke = stroke; + } + + /// Get the current stroke. + pub fn stroke(&self) -> &Stroke { + &self.state.stroke + } + + /// Get a mutable reference to the current stroke. + #[cfg(feature = "text")] + pub(crate) fn stroke_mut(&mut self) -> &mut Stroke { + &mut self.state.stroke + } + + /// Set the current paint. + /// + /// If the paint is an image with `ImageSource::OpaqueId`, it will be + /// resolved to the corresponding pixmap at rasterization time. + /// Make sure to register images with [`Resources::register_image`] first. + pub fn set_paint(&mut self, paint: impl Into) { + self.state.paint = paint.into(); + } + + /// Get the current paint. + pub fn paint(&self) -> &PaintType { + &self.state.paint + } + + /// Set the tint for subsequent image paint operations. + pub fn set_tint(&mut self, tint: Option) { + self.state.tint = tint; + } + + /// Clear the tint, so subsequent image paints are drawn without tinting. + pub fn reset_tint(&mut self) { + self.state.tint = None; + } + + /// Set the blend mode that should be used when drawing objects. + pub fn set_blend_mode(&mut self, blend_mode: BlendMode) { + self.state.blend_mode = blend_mode; + } + + /// Get the currently active blend mode. + pub fn blend_mode(&self) -> BlendMode { + self.state.blend_mode + } + + /// Set the current paint transform. + /// + /// The paint transform is applied to the paint after the transform of the geometry the paint + /// is drawn in, i.e., the paint transform is applied after the global transform. This allows + /// transforming the paint independently from the drawn geometry. + pub fn set_paint_transform(&mut self, paint_transform: Affine) { + self.state.paint_transform = paint_transform; + } + + /// Get the current paint transform. + pub fn paint_transform(&self) -> &Affine { + &self.state.paint_transform + } + + /// Reset the current paint transform. + pub fn reset_paint_transform(&mut self) { + self.state.paint_transform = Affine::IDENTITY; + } + + /// Set the current fill rule. + pub fn set_fill_rule(&mut self, fill_rule: Fill) { + self.state.fill_rule = fill_rule; + } + + /// Set the mask to use for path-painting operations. The mask needs to + /// have the same dimensions as the render context. The mask will not be + /// affected by the current transform in place. + /// + /// See the explanation in the [masking](https://github.com/linebender/vello/tree/main/sparse_strips/masking/examples) + /// example for how this method differs from `push_mask_layer`. + pub fn set_mask(&mut self, mask: Mask) { + self.mask = Some(mask); + } + + /// Reset the mask that is used for path-painting operations. + pub fn reset_mask(&mut self) { + self.mask = None; + } + + /// Get the current fill rule. + pub fn fill_rule(&self) -> &Fill { + &self.state.fill_rule + } + + /// Set the current transform. + pub fn set_transform(&mut self, transform: Affine) { + self.state.transform = transform; + } + + /// Get the current transform. + pub fn transform(&self) -> &Affine { + &self.state.transform + } + + /// Reset the current transform. + pub fn reset_transform(&mut self) { + self.state.transform = Affine::IDENTITY; + } + + /// Apply filter to the current paint (affects next drawn elements). + /// + /// This sets a filter that will be applied to the next drawn element. + /// To apply a filter to multiple elements, use `push_filter_layer` instead. + pub fn set_filter_effect(&mut self, filter: Filter) { + self.filter = Some(filter); + } + + /// Reset the current filter effect. + pub fn reset_filter_effect(&mut self) { + self.filter = None; + } + + /// Reset the render context. + pub fn reset(&mut self) { + self.dispatcher.reset(); + self.encoded_paints.clear(); + self.mask = None; + self.state.reset(); + } + + /// Push a new clip path to the clip stack. + /// + /// See the explanation in the [clipping](https://github.com/linebender/vello/tree/main/sparse_strips/vello_cpu/examples) + /// example for how this method differs from `push_clip_layer`. + pub fn push_clip_path(&mut self, path: &BezPath) { + self.dispatcher.push_clip_path( + path, + self.state.fill_rule, + self.state.transform, + self.aliasing_threshold, + ); + } + + /// Pop a clip path from the clip stack. + /// + /// Note that unlike `push_clip_layer`, it is permissible to have pending + /// pushed clip paths before finishing the rendering operation. + pub fn pop_clip_path(&mut self) { + self.dispatcher.pop_clip_path(); + } + + /// Flush any pending operations. + /// + /// This is a no-op when using the single-threaded render mode, and can be ignored. + /// For multi-threaded rendering, you _have_ to call this before rasterizing, otherwise + /// the program will panic. + pub fn flush(&mut self) { + self.dispatcher.flush(&self.encoded_paints); + } + + /// Render the current context into a buffer. + /// The buffer is expected to be in premultiplied RGBA8 format with length `width * height * 4` + pub fn render_to_buffer( + &self, + resources: &mut Resources, + buffer: &mut [u8], + width: u16, + height: u16, + render_mode: RenderMode, + ) { + // TODO: Maybe we should move those checks into the dispatcher. + let wide = self.dispatcher.wide(); + assert!(!wide.has_layers(), "some layers haven't been popped yet"); + assert_eq!( + buffer.len(), + (width as usize) * (height as usize) * 4, + "provided width ({}) and height ({}) do not match buffer size ({})", + width, + height, + buffer.len(), + ); + + resources.before_render(); + + self.dispatcher.rasterize( + buffer, + render_mode, + width, + height, + &self.encoded_paints, + &resources.image_registry, + ); + // TODO: We need to figure something out here API-wise. At the moment, the user can + // theoretically rasterize the same `RenderContext` multiple times without resetting in-between. + // However, if glyph caching is enabled, this method call could now evict that were previously + // assumed to exist in `RenderContext`, meaning that if the user rasterizes the same `RenderContext` + // again without resetting it, some of the cached glyphs might be stale and not exist anymore. + resources.after_render(); + } + + /// Render the current context into a pixmap. + pub fn render_to_pixmap(&self, resources: &mut Resources, pixmap: &mut Pixmap) { + let width = pixmap.width(); + let height = pixmap.height(); + self.render_to_buffer( + resources, + pixmap.data_as_u8_slice_mut(), + width, + height, + self.render_settings.render_mode, + ); + } + + /// Composite the current context into a region of a pixmap. + /// + /// The context's content (sized `self.width × self.height`) is composited + /// directly to the destination pixmap starting at `(dst_x, dst_y)`. + /// If the region extends beyond the pixmap bounds, it is clipped. + /// + /// Unlike [`render_to_pixmap`](Self::render_to_pixmap), this method composites on top of + /// existing pixmap content rather than clearing it first, allowing multiple + /// renders to accumulate. + /// + /// This is useful for rendering individual elements (like glyphs) into + /// a spritesheet at specific coordinates. + /// + /// # Panics + /// + /// This method is only supported with the single-threaded dispatcher and will + /// **panic** if called on a `RenderContext` using the multi-threaded dispatcher. + pub fn composite_to_pixmap_at_offset( + &self, + resources: &Resources, + pixmap: &mut Pixmap, + dst_x: u16, + dst_y: u16, + ) { + let dst_buffer_width = pixmap.width(); + let dst_buffer_height = pixmap.height(); + self.dispatcher.composite_at_offset( + pixmap.data_as_u8_slice_mut(), + self.width, + self.height, + dst_x, + dst_y, + dst_buffer_width, + dst_buffer_height, + self.render_settings.render_mode, + &self.encoded_paints, + &resources.image_registry, + ); + } + + /// 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 + } + + /// Return the render settings used by the `RenderContext`. + pub fn render_settings(&self) -> &RenderSettings { + &self.render_settings + } + + /// Execute a drawing operation, optionally wrapping it in a filter layer. + fn with_optional_filter(&mut self, mut f: F) + where + F: FnMut(&mut Self), + { + if let Some(filter) = self.filter.clone() { + self.push_filter_layer(filter); + f(self); + self.pop_layer(); + } else { + f(self); + } + } + + /// Take current rendering state and reset the existing state to its default. + pub fn take_current_state(&mut self) -> RenderState { + core::mem::take(&mut self.state) + } + + /// Save a copy of the current rendering state. + pub fn save_current_state(&mut self) -> RenderState { + self.state.clone() + } + + /// Restore rendering state. + pub fn restore_state(&mut self, state: RenderState) { + self.state = state; + } +} + +/// Image registry implementation. +impl Resources { + /// Register a pixmap in the image registry and return its [`ImageId`]. + pub fn register_image(&mut self, pixmap: Arc) -> ImageId { + self.image_registry.register(pixmap) + } + + /// Remove an image from the registry. + pub fn destroy_image(&mut self, id: ImageId) -> bool { + self.image_registry.destroy(id) + } + + /// Resolve an `ImageId` to its pixmap data. + pub fn resolve_image(&self, id: ImageId) -> Option> { + self.image_registry.resolve(id) + } + + /// Clear the image registry. + pub fn clear_images(&mut self) { + self.image_registry.clear(); + } +} + +/// Registry that maps opaque [`ImageId`]s to [`Pixmap`] data. +/// +/// Used by [`RenderContext`] to resolve `ImageSource::OpaqueId` at rasterization time. +#[derive(Debug, Default)] +pub(crate) struct ImageRegistry { + images: HashMap>, + next_id: u32, +} + +impl ImageRegistry { + fn register(&mut self, pixmap: Arc) -> ImageId { + let id = self.next_id; + assert!( + id < ATLAS_IMAGE_ID_BASE, + "image registry exhausted non-atlas image IDs" + ); + + self.next_id += 1; + self.images.insert(id, pixmap); + ImageId::new(id) + } + + #[cfg(feature = "text")] + pub(crate) fn register_atlas_page(&mut self, page_index: u32, pixmap: Arc) { + self.images.insert( + ImageId::new(ATLAS_IMAGE_ID_BASE + page_index).as_u32(), + pixmap, + ); + } + + pub(crate) fn destroy(&mut self, id: ImageId) -> bool { + self.images.remove(&id.as_u32()).is_some() + } + + #[cfg(feature = "text")] + pub(crate) fn destroy_atlas_page(&mut self, page_index: u32) -> bool { + self.destroy(ImageId::new(ATLAS_IMAGE_ID_BASE + page_index)) + } + + fn resolve(&self, id: ImageId) -> Option> { + self.images.get(&id.as_u32()).cloned() + } + + fn clear(&mut self) { + self.images.clear(); + self.next_id = 0; + } +} + +impl ImageResolver for ImageRegistry { + fn resolve(&self, id: ImageId) -> Option> { + self.images.get(&id.as_u32()).cloned() + } +} + +#[cfg(test)] +mod tests { + use crate::RenderContext; + #[cfg(feature = "text")] + use crate::peniko::{Blob, FontData}; + #[cfg(feature = "text")] + use alloc::sync::Arc; + #[cfg(feature = "text")] + use glifo::Glyph; + use vello_common::kurbo::{Rect, Shape}; + use vello_common::tile::Tile; + + #[test] + fn clip_overflow() { + let mut ctx = RenderContext::new(100, 100); + + for _ in 0..(usize::from(u16::MAX) + 1).div_ceil(usize::from(Tile::HEIGHT * Tile::WIDTH)) { + ctx.fill_rect(&Rect::new(0.0, 0.0, 1.0, 1.0)); + } + + ctx.push_clip_layer(&Rect::new(20.0, 20.0, 180.0, 180.0).to_path(0.1)); + ctx.pop_layer(); + ctx.flush(); + } + + #[cfg(feature = "multithreading")] + #[test] + fn multithreaded_crash_after_reset() { + use crate::{Level, RenderMode, RenderSettings}; + use vello_common::pixmap::Pixmap; + + let mut pixmap = Pixmap::new(200, 200); + let settings = RenderSettings { + level: Level::try_detect().unwrap_or(Level::baseline()), + num_threads: 1, + render_mode: RenderMode::OptimizeQuality, + }; + + let mut resources = crate::Resources::new(); + let mut ctx = RenderContext::new_with(200, 200, settings); + ctx.reset(); + ctx.fill_path(&Rect::new(0.0, 0.0, 100.0, 100.0).to_path(0.1)); + ctx.flush(); + ctx.render_to_pixmap(&mut resources, &mut pixmap); + ctx.flush(); + ctx.render_to_pixmap(&mut resources, &mut pixmap); + } + + #[cfg(feature = "text")] + #[test] + fn glyph_atlas_resources_are_lazy() { + const ROBOTO_FONT: &[u8] = + include_bytes!("../../../examples/assets/roboto/Roboto-Regular.ttf"); + + let font = FontData::new(Blob::new(Arc::new(ROBOTO_FONT)), 0); + let glyphs = [Glyph { + id: 1, + x: 0.0, + y: 0.0, + }]; + + let mut resources = crate::Resources::new(); + let mut ctx = RenderContext::new(100, 100); + + ctx.fill_rect(&Rect::new(0.0, 0.0, 10.0, 10.0)); + ctx.fill_path(&Rect::new(10.0, 10.0, 20.0, 20.0).to_path(0.1)); + ctx.glyph_run(&mut resources, &font) + .fill_glyphs(glyphs.into_iter()); + + assert!(resources.glyph_resources.is_none()); + + ctx.glyph_run(&mut resources, &font) + .atlas_cache(true) + .fill_glyphs(glyphs.into_iter()); + + assert!(resources.glyph_resources.is_some()); + } +} diff --git a/vendor/vello_cpu/src/text.rs b/vendor/vello_cpu/src/text.rs new file mode 100644 index 0000000000..67fb7a714b --- /dev/null +++ b/vendor/vello_cpu/src/text.rs @@ -0,0 +1,498 @@ +// Copyright 2025 the Vello Authors and the Parley Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Vello CPU glyph rendering backend. +//! +//! Provides [`GlyphAtlas`] (atlas backed by per-page [`Pixmap`]s) and the +//! [`GlyphRenderer`](glifo::GlyphRenderer) implementation for [`RenderContext`] that +//! rasterises glyphs into CPU-accessible pixel buffers. +//! +//! The key difference from the hybrid backend is that atlas pages are owned as +//! [`Arc`]s here, so the CPU renderer can read pixels directly without +//! any GPU upload step. + +use crate::render::{ATLAS_IMAGE_ID_BASE, DEFAULT_GLYPH_ATLAS_SIZE}; +use crate::{ + Image, ImageSource, PaintType, Pixmap, RenderContext, RenderMode, RenderSettings, Resources, + color, kurbo, peniko, +}; +use alloc::boxed::Box; +use alloc::sync::Arc; +use alloc::vec::Vec; +use color::palette::css::BLACK; +use core::fmt::Debug; +use core::ops::RangeInclusive; +use glifo::atlas::{ + AtlasConfig, AtlasSlot, GlyphAtlas, GlyphCacheConfig, ImageCache, PendingClearRect, +}; +use glifo::{AtlasCacher, DrawSink, GlyphRunBackend}; +use glifo::{Glyph, renderer}; +use kurbo::{Affine, BezPath, Rect}; +use peniko::BlendMode; +use peniko::color::{AlphaColor, Srgb}; +use vello_common::fearless_simd::Level; +use vello_common::paint::ImageId; + +fn atlas_page_image_id(page_index: u32) -> ImageId { + ImageId::new(ATLAS_IMAGE_ID_BASE + page_index) +} + +#[derive(Debug)] +pub(crate) struct GlyphAtlasResources { + pub(crate) glyph_atlas: GlyphAtlas, + pub(crate) image_cache: ImageCache, + pub(crate) glyph_renderer: Box, + /// One `Pixmap` per atlas page, grown on demand. + // It's a bit annoying to have this in an `Arc`, but it needs to be this way. During fine + // rasterization, we need to be able to easily clone the atlas page so that it can be shared + // across multiple threads. However, we also need to be able to mutate the pixmap to + // sync new glyphs. The way this is achieved is by calling `Arc::get_mut` when syncing + // (at this point, the pixmap isn't shared anywhere else). Before fine rasterization, we + // then share it with the image registry such that it can easily be fetched and cloned during + // fine rasterization. After that, we remove it from the image registry, such that it's uniquely + // owned again and can be mutated in the next frame. + pub(crate) pixmaps: Vec>, + /// Width of each atlas page in pixels. + page_width: u16, + /// Height of each atlas page in pixels. + page_height: u16, +} + +impl GlyphAtlasResources { + pub(crate) fn with_config( + page_width: u16, + page_height: u16, + level: Level, + render_mode: RenderMode, + eviction_config: GlyphCacheConfig, + ) -> Self { + Self { + glyph_atlas: GlyphAtlas::with_config(eviction_config), + image_cache: ImageCache::new_with_config(AtlasConfig::default()), + glyph_renderer: Box::new(RenderContext::new_with( + page_width, + page_height, + RenderSettings { + level, + num_threads: 0, + render_mode, + }, + )), + pixmaps: Vec::new(), + page_width, + page_height, + } + } + + pub(crate) fn maintain(&mut self) { + self.glyph_atlas.maintain(&mut self.image_cache); + } +} + +/// Ensure a pixmap exists for the given page, creating it if needed. +fn ensure_page( + pixmaps: &mut Vec>, + page_width: u16, + page_height: u16, + page_index: usize, +) { + while pixmaps.len() <= page_index { + pixmaps.push(Arc::new(Pixmap::new(page_width, page_height))); + } +} + +impl Resources { + pub(crate) fn prepare_glyph_cache(&mut self) { + if self.glyph_resources.is_some() { + self.sync_glyph_cache(); + } + } + + pub(crate) fn maintain_glyph_cache(&mut self) { + self.glyph_prep_cache.maintain(); + + if let Some(glyph_resources) = self.glyph_resources.as_mut() { + glyph_resources.maintain(); + let page_count = glyph_resources.pixmaps.len(); + for page_index in 0..page_count { + self.image_registry.destroy_atlas_page(page_index as u32); + } + self.clear_evicted_glyph_atlas_regions(); + } + } + + fn ensure_glyph_resources(&mut self, level: Level, render_mode: RenderMode) { + if self.glyph_resources.is_none() { + self.glyph_resources = Some(GlyphAtlasResources::with_config( + DEFAULT_GLYPH_ATLAS_SIZE, + DEFAULT_GLYPH_ATLAS_SIZE, + level, + render_mode, + GlyphCacheConfig::default(), + )); + } + } + + /// Upload all pending bitmaps, rasterize pending outline/COLR glyphs, etc. + fn sync_glyph_cache(&mut self) { + let glyph_resources = self + .glyph_resources + .as_mut() + .expect("glyph atlas resources must exist before syncing"); + + // Upload all pending bitmap glyphs to the image atlas. + for upload in glyph_resources.glyph_atlas.drain_pending_uploads() { + let page_index = upload.atlas_slot.page_index as usize; + ensure_page( + &mut glyph_resources.pixmaps, + glyph_resources.page_width, + glyph_resources.page_height, + page_index, + ); + let pixmap = Arc::get_mut(&mut glyph_resources.pixmaps[page_index]) + .expect("atlas pixmap should be uniquely owned during bitmap upload"); + copy_pixmap_to_atlas( + &upload.pixmap, + pixmap, + upload.atlas_slot.x, + upload.atlas_slot.y, + upload.atlas_slot.width, + upload.atlas_slot.height, + ); + } + + // Draw all new COLR/outline glyphs into the render context, and then composite them into the + // existing atlas page. + let glyph_renderer = glyph_resources.glyph_renderer.as_mut(); + glyph_resources + .glyph_atlas + .replay_pending_atlas_commands(|recorder| { + let page_index = recorder.page_index as usize; + ensure_page( + &mut glyph_resources.pixmaps, + glyph_resources.page_width, + glyph_resources.page_height, + page_index, + ); + let page = Arc::get_mut(&mut glyph_resources.pixmaps[page_index]) + .expect("atlas page pixmap must be uniquely owned during replay"); + + glyph_renderer.reset(); + renderer::replay_atlas_commands(&mut recorder.commands, glyph_renderer); + glyph_renderer.flush(); + glyph_renderer.composite_to_pixmap_at_offset(&Self::default(), page, 0, 0); + }); + + for (page_index, pixmap) in glyph_resources.pixmaps.iter().enumerate() { + self.image_registry + .register_atlas_page(page_index as u32, Arc::clone(pixmap)); + } + } + + fn clear_evicted_glyph_atlas_regions(&mut self) { + let glyph_resources = self + .glyph_resources + .as_mut() + .expect("glyph atlas resources must exist before clearing"); + for clear in glyph_resources.glyph_atlas.drain_pending_clear_rects() { + let pixmap = Arc::get_mut(&mut glyph_resources.pixmaps[clear.page_index as usize]) + .expect("atlas pixmap should be uniquely owned during region clearing"); + clear_pixmap_region(pixmap, clear); + } + } +} + +#[doc(hidden)] +#[derive(Debug)] +pub struct CpuGlyphRunBackend<'a> { + pub ctx: &'a mut RenderContext, + pub resources: &'a mut Resources, + pub atlas_cache_enabled: bool, +} + +impl<'a> CpuGlyphRunBackend<'a> { + fn render_glyphs( + self, + run: glifo::GlyphRun<'a>, + glyphs: Glyphs, + render: impl FnOnce(&mut glifo::GlyphRunRenderer<'a, 'a, Glyphs>, &mut RenderContext), + ) where + Glyphs: Iterator + Clone, + { + let atlas_cacher = if self.atlas_cache_enabled { + self.resources.ensure_glyph_resources( + self.ctx.render_settings.level, + self.ctx.render_settings.render_mode, + ); + let glyph_resources = self + .resources + .glyph_resources + .as_mut() + .expect("glyph atlas resources must exist after initialization"); + AtlasCacher::Enabled( + &mut glyph_resources.glyph_atlas, + &mut glyph_resources.image_cache, + ) + } else { + AtlasCacher::Disabled + }; + + let mut glyph_run = run.build( + glyphs, + self.resources.glyph_prep_cache.as_mut(), + atlas_cacher, + ); + render(&mut glyph_run, self.ctx); + } +} + +impl<'a> GlyphRunBackend<'a> for CpuGlyphRunBackend<'a> { + fn atlas_cache(mut self, enabled: bool) -> Self { + self.atlas_cache_enabled = enabled; + self + } + + fn fill_glyphs(self, run: glifo::GlyphRun<'a>, glyphs: Glyphs) + where + Glyphs: Iterator + Clone, + { + self.render_glyphs(run, glyphs, |glyph_run, ctx| glyph_run.fill_glyphs(ctx)); + } + + fn stroke_glyphs(self, run: glifo::GlyphRun<'a>, glyphs: Glyphs) + where + Glyphs: Iterator + Clone, + { + self.render_glyphs(run, glyphs, |glyph_run, ctx| { + let stroke_adjustment = glyph_run.stroke_adjustment(); + let original_width = ctx.stroke().width; + ctx.stroke_mut().width *= stroke_adjustment; + glyph_run.stroke_glyphs(ctx); + ctx.stroke_mut().width = original_width; + }); + } + + fn render_decoration( + self, + run: glifo::GlyphRun<'a>, + glyphs: Glyphs, + x_range: RangeInclusive, + baseline_y: f32, + offset: f32, + size: f32, + buffer: f32, + ) where + Glyphs: Iterator + Clone, + { + self.render_glyphs(run, glyphs, |glyph_run, ctx| { + glyph_run.render_decoration(x_range, baseline_y, offset, size, buffer, ctx); + }); + } +} + +/// A glyph run builder. +pub type GlyphRunBuilder<'a> = glifo::GlyphRunBuilder<'a, CpuGlyphRunBackend<'a>>; + +/// Zero out a rectangular region in the atlas pixmap. +/// +/// Necessary because `composite_to_pixmap_at_offset` uses `SrcOver` blending, +/// so stale pixels from evicted glyphs would bleed through if not cleared. +fn clear_pixmap_region(dst: &mut Pixmap, rect: PendingClearRect) { + let dst_stride = dst.width() as usize; + let dst_data = dst.data_as_u8_slice_mut(); + let clear_width = rect.width as usize; + let clear_height = rect.height as usize; + + for y in 0..clear_height { + let row_start = ((rect.y as usize + y) * dst_stride + rect.x as usize) * 4; + let row_end = row_start + clear_width * 4; + dst_data[row_start..row_end].fill(0); + } +} + +/// Copy bitmap glyph pixels into a rectangular region of an atlas page. +fn copy_pixmap_to_atlas( + src: &Pixmap, + dst: &mut Pixmap, + dst_x: u16, + dst_y: u16, + width: u16, + height: u16, +) { + let copy_width = width as usize; + let copy_height = height as usize; + let src_stride = src.width() as usize; + let dst_stride = dst.width() as usize; + + let src_data = src.data_as_u8_slice(); + let dst_data = dst.data_as_u8_slice_mut(); + + for y in 0..copy_height { + let src_row_start = y * src_stride * 4; + let src_row_end = src_row_start + copy_width * 4; + let dst_row_start = ((dst_y as usize + y) * dst_stride + dst_x as usize) * 4; + let dst_row_end = dst_row_start + copy_width * 4; + + dst_data[dst_row_start..dst_row_end].copy_from_slice(&src_data[src_row_start..src_row_end]); + } +} + +impl DrawSink for RenderContext { + #[inline] + fn set_transform(&mut self, t: Affine) { + Self::set_transform(self, t); + } + + #[inline] + fn set_paint(&mut self, paint: glifo::AtlasPaint) { + Self::set_paint(self, paint); + } + + #[inline] + fn set_paint_transform(&mut self, t: Affine) { + Self::set_paint_transform(self, t); + } + + #[inline] + fn fill_path(&mut self, path: &BezPath) { + Self::fill_path(self, path); + } + + #[inline] + fn fill_rect(&mut self, rect: &Rect) { + Self::fill_rect(self, rect); + } + + #[inline] + fn push_clip_layer(&mut self, clip: &BezPath) { + Self::push_clip_layer(self, clip); + } + + #[inline] + fn push_clip_path(&mut self, clip: &BezPath) { + Self::push_clip_path(self, clip); + } + + #[inline] + fn push_blend_layer(&mut self, blend_mode: BlendMode) { + Self::push_blend_layer(self, blend_mode); + } + + #[inline] + fn pop_layer(&mut self) { + Self::pop_layer(self); + } + + #[inline] + fn pop_clip_path(&mut self) { + Self::pop_clip_path(self); + } + + #[inline] + fn width(&self) -> u16 { + Self::width(self) + } + + #[inline] + fn height(&self) -> u16 { + Self::height(self) + } +} + +impl glifo::GlyphRenderer for RenderContext { + type SavedState = vello_common::render_state::RenderState; + + #[inline] + fn save_state(&mut self) -> Self::SavedState { + self.save_current_state() + } + + #[inline] + fn restore_state(&mut self, state: Self::SavedState) { + Self::restore_state(self, state); + } + + #[inline] + fn stroke_path(&mut self, path: &BezPath) { + Self::stroke_path(self, path); + } + + #[inline] + fn set_paint_image(&mut self, image: Image) { + self.set_paint(image); + } + + #[inline] + fn set_tint(&mut self, tint: Option) { + Self::set_tint(self, tint); + } + + #[inline] + fn get_context_color(&self) -> AlphaColor { + let paint = self.paint().clone(); + match paint { + PaintType::Solid(s) => s, + _ => BLACK, + } + } + + #[inline] + fn atlas_image_source(&self, atlas_slot: &AtlasSlot) -> ImageSource { + ImageSource::opaque_id(atlas_page_image_id(atlas_slot.page_index)) + } + + #[inline] + fn atlas_paint_transform(&self, atlas_slot: &AtlasSlot) -> Affine { + Affine::translate((-(atlas_slot.x as f64), -(atlas_slot.y as f64))) + } +} + +/// Debug utilities for visualizing glyph bounds during rasterization. +#[cfg(debug_assertions)] +#[allow( + dead_code, + unreachable_pub, + clippy::trivially_copy_pass_by_ref, + reason = "debug-only utilities called manually during development" +)] +mod debug { + use core::sync::atomic::{AtomicUsize, Ordering}; + + use crate::RenderContext; + use crate::kurbo::{Affine, Rect}; + use crate::peniko; + use glifo::atlas::RasterMetrics; + + static COLOR_INDEX: AtomicUsize = AtomicUsize::new(0); + + /// Twelve semi-transparent rotating colours for distinguishing adjacent + /// glyph bounds. Each call to [`fill_glyph_bounds`] advances the index. + const COLORS: [peniko::Color; 12] = [ + peniko::Color::new([1.0, 0.0, 0.0, 0.5]), // Red + peniko::Color::new([0.0, 1.0, 0.0, 0.5]), // Green + peniko::Color::new([0.0, 0.0, 1.0, 0.5]), // Blue + peniko::Color::new([1.0, 1.0, 0.0, 0.5]), // Yellow + peniko::Color::new([1.0, 0.0, 1.0, 0.5]), // Magenta + peniko::Color::new([0.0, 1.0, 1.0, 0.5]), // Cyan + peniko::Color::new([1.0, 0.5, 0.0, 0.5]), // Orange + peniko::Color::new([0.5, 0.0, 1.0, 0.5]), // Purple + peniko::Color::new([0.0, 1.0, 0.5, 0.5]), // Mint + peniko::Color::new([1.0, 0.5, 0.5, 0.5]), // Pink + peniko::Color::new([0.5, 1.0, 0.5, 0.5]), // Light green + peniko::Color::new([0.5, 0.5, 1.0, 0.5]), // Light blue + ]; + + /// Fill the glyph's pixel bounds with the next rotating debug colour. + /// Call before rendering the actual glyph content to visualise the extent. + pub fn fill_glyph_bounds(renderer: &mut RenderContext, raster_metrics: &RasterMetrics) { + let idx = COLOR_INDEX.fetch_add(1, Ordering::Relaxed) % COLORS.len(); + renderer.set_transform(Affine::IDENTITY); + renderer.set_paint(COLORS[idx]); + renderer.fill_rect(&Rect::new( + 0.0, + 0.0, + raster_metrics.width as f64, + raster_metrics.height as f64, + )); + } +} diff --git a/vendor/vello_cpu/src/text_debug.rs b/vendor/vello_cpu/src/text_debug.rs new file mode 100644 index 0000000000..aef708a4eb --- /dev/null +++ b/vendor/vello_cpu/src/text_debug.rs @@ -0,0 +1,108 @@ +// Copyright 2026 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Debug helpers for glyph atlas and CPU text resources. + +#![allow(dead_code, reason = "only used for debugging purposes")] + +#[cfg(feature = "png")] +use crate::Pixmap; +#[cfg(feature = "png")] +use crate::render::Resources; +use crate::text::GlyphAtlasResources; +#[cfg(feature = "png")] +use alloc::format; +use glifo::GlyphCacheKey; +use glifo::atlas::GlyphCacheStats; + +#[cfg(feature = "png")] +impl GlyphAtlasResources { + /// Save all atlas pages to PNG files with a custom path prefix. + /// + /// Files are saved as `{path_prefix}_atlas_page_{index}.png`. + pub(crate) fn save_atlas_pages_to(&self, path_prefix: &str) { + for (i, pixmap) in self.pixmaps.iter().enumerate() { + let path = format!("{path_prefix}_atlas_page_{i}.png"); + let _ = save_pixmap_to_png(pixmap, std::path::Path::new(&path)); + } + } + + /// Save all atlas pages to PNG files for debugging. + /// + /// Files are saved to `examples/_output/vello_cpu_atlas_page_{index}.png`. + pub(crate) fn save_atlas_pages(&self) { + for (i, pixmap) in self.pixmaps.iter().enumerate() { + let mut path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + path.pop(); // up from vello_cpu to sparse_strips + path.pop(); // up from sparse_strips to workspace root + path.push("examples"); + path.push("_output"); + let _ = std::fs::create_dir_all(&path); + path.push(format!("vello_cpu_atlas_page_{i}.png")); + let _ = save_pixmap_to_png(pixmap, &path); + } + } +} + +impl GlyphAtlasResources { + /// Get detailed statistics about cached glyphs. + pub(crate) fn stats(&self) -> GlyphCacheStats { + self.glyph_atlas.stats(self.pixmaps.len()) + } + + /// Log detailed atlas statistics at info level. + pub(crate) fn log_atlas_stats(&self) { + self.glyph_atlas.log_atlas_stats(self.pixmaps.len()); + } + + /// Returns all cached glyph keys (for debugging). + pub(crate) fn all_keys(&self) -> impl Iterator { + self.glyph_atlas.all_keys() + } + + /// Log all cached keys grouped by glyph ID at info level. + pub(crate) fn log_keys_grouped(&self) { + self.glyph_atlas.log_keys_grouped(); + } +} + +#[cfg(feature = "png")] +impl Resources { + pub(crate) fn save_glyph_atlas_pages(&self) { + if let Some(glyph_resources) = &self.glyph_resources { + glyph_resources.save_atlas_pages(); + } + } + + pub(crate) fn save_glyph_atlas_pages_to(&self, path_prefix: &str) { + if let Some(glyph_resources) = &self.glyph_resources { + glyph_resources.save_atlas_pages_to(path_prefix); + } + } +} + +/// Save a pixmap to a PNG file (diagnostic utility). +#[cfg(feature = "png")] +pub(crate) fn save_pixmap_to_png(pixmap: &Pixmap, path: &std::path::Path) -> std::io::Result<()> { + use std::fs::File; + use std::io::BufWriter; + + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + + let file = File::create(path)?; + let w = BufWriter::new(file); + + let mut encoder = png::Encoder::new(w, pixmap.width() as u32, pixmap.height() as u32); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + + let mut writer = encoder.write_header().map_err(std::io::Error::other)?; + + writer + .write_image_data(pixmap.data_as_u8_slice()) + .map_err(std::io::Error::other)?; + + Ok(()) +} diff --git a/vendor/vello_cpu/src/util.rs b/vendor/vello_cpu/src/util.rs new file mode 100644 index 0000000000..b0e692772d --- /dev/null +++ b/vendor/vello_cpu/src/util.rs @@ -0,0 +1,112 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use crate::peniko::ImageQuality; +use vello_common::encode::EncodedImage; +use vello_common::fearless_simd::{Simd, SimdBase, f32x4, u8x32}; +use vello_common::math::FloatExt; +use vello_common::util::Div255Ext; + +pub(crate) mod scalar { + /// Perform an approximate division by 255. + /// + /// There are three reasons for having this method. + /// 1) Divisions are slower than shifting + adding, and the compiler does not seem to replace + /// divisions by 255 with an equivalent (this was verified by benchmarking; doing / 255 was + /// significantly slower). + /// 2) Integer divisions are usually not available in SIMD, so this provides a good baseline + /// implementation. + /// 3) There are two options for performing the division: One is to perform the division + /// in a way that completely preserves the rounding semantics of a integer division by + /// 255. This could be achieved using the implementation `(val + 1 + (val >> 8)) >> 8`. + /// The second approach (used here) has slightly different rounding behavior to a + /// normal division by 255, but is much faster (see ) + /// and therefore preferable for the high-performance pipeline. + /// + /// Four properties worth mentioning: + /// - This actually calculates the ceiling of `val / 256`. + /// - Within the allowed range for `val`, rounding errors do not appear for values divisible by 255, i.e. any call `div_255(val * 255)` will always yield `val`. + /// - If there is a discrepancy, this division will always yield a value 1 higher than the original. + /// - This holds for values of `val` up to and including `65279`. You should not call this function with higher values. + #[inline(always)] + pub(crate) const fn div_255(val: u16) -> u16 { + debug_assert!( + val < 65280, + "the properties of `div_255` do not hold for values of `65280` or greater" + ); + (val + 255) >> 8 + } + + #[cfg(test)] + mod tests { + use crate::util::scalar::div_255; + + #[test] + fn div_255_properties() { + for i in 0_u16..256 * 255 { + let expected = i / 255; + let actual = div_255(i); + + assert!( + expected <= actual, + "In case of a discrepancy, the division should yield a value higher than the original." + ); + + let diff = expected.abs_diff(actual); + assert!(diff <= 1, "Rounding error shouldn't be higher than 1."); + + if i % 255 == 0 { + assert_eq!(diff, 0, "Division should be accurate for multiples of 255."); + } + } + } + } +} + +pub(crate) trait NormalizedMulExt { + fn normalized_mul(self, other: Self) -> Self; +} + +impl NormalizedMulExt for u8x32 { + #[inline(always)] + fn normalized_mul(self, other: Self) -> Self { + let divided = (self.simd.widen_u8x32(self) * other.simd.widen_u8x32(other)).div_255(); + self.simd.narrow_u16x32(divided) + } +} + +pub(crate) trait EncodedImageExt { + fn has_skew(&self) -> bool; + fn nearest_neighbor(&self) -> bool; +} + +impl EncodedImageExt for EncodedImage { + fn has_skew(&self) -> bool { + !(self.x_advance.y as f32).is_nearly_zero() || !(self.y_advance.x as f32).is_nearly_zero() + } + + fn nearest_neighbor(&self) -> bool { + self.sampler.quality == ImageQuality::Low + } +} + +pub(crate) trait Premultiply { + fn premultiply(self, alphas: Self) -> Self; + fn unpremultiply(self, alphas: Self) -> Self; +} + +impl Premultiply for f32x4 { + #[inline(always)] + fn premultiply(self, alphas: Self) -> Self { + self * alphas + } + + #[inline(always)] + fn unpremultiply(self, alphas: Self) -> Self { + let zero = Self::splat(alphas.simd, 0.0); + let divided = self / alphas; + + self.simd + .select_f32x4(self.simd.simd_eq_f32x4(alphas, zero), zero, divided) + } +} diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index 7a33bad875..028282e073 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -5,6 +5,9 @@ edition = "2024" publish = false license.workspace = true +[lints] +workspace = true + [dependencies] pdfcraft-oracle = { workspace = true } pdfcraft-cos = { workspace = true } diff --git a/xtask/src/demo_pdf/mod.rs b/xtask/src/demo_pdf.rs similarity index 100% rename from xtask/src/demo_pdf/mod.rs rename to xtask/src/demo_pdf.rs diff --git a/xtask/src/demo_pdf/form.rs b/xtask/src/demo_pdf/form.rs index e4d782bc0e..39f918feba 100644 --- a/xtask/src/demo_pdf/form.rs +++ b/xtask/src/demo_pdf/form.rs @@ -38,6 +38,15 @@ struct Builder<'a> { fields: Vec, } +struct TextFieldSpec<'a> { + field: &'a str, + tooltip: &'a str, + rect: Rect, + value: &'a str, + display: Option<&'a str>, + quadding: i64, +} + pub fn build(doc: &mut Document, annotator: &mut Annotator, inputs: FormInputs<'_>) -> FormPage { let page = doc.new_object_id(); let mut b = Builder { doc, fonts: inputs.fonts, page, widgets: Vec::new(), fields: Vec::new() }; @@ -48,26 +57,76 @@ pub fn build(doc: &mut Document, annotator: &mut Annotator, inputs: FormInputs<' section(&mut c, 612.0, "Contact"); let row1 = 575.0; label(&mut c, LEFT, row1 + 24.0, "Full name"); - b.text_field("name", "Full name", Rect::new(LEFT, row1, 296.0, row1 + 20.0), "Ada Typesetter", None, 0, |_| {}); + b.text_field( + TextFieldSpec { + field: "name", + tooltip: "Full name", + rect: Rect::new(LEFT, row1, 296.0, row1 + 20.0), + value: "Ada Typesetter", + display: None, + quadding: 0, + }, + |_| {}, + ); label(&mut c, COL2, row1 + 24.0, "Email"); - b.text_field("email", "Email address", Rect::new(COL2, row1, RIGHT, row1 + 20.0), "ada@example.com", None, 0, |_| {}); + b.text_field( + TextFieldSpec { + field: "email", + tooltip: "Email address", + rect: Rect::new(COL2, row1, RIGHT, row1 + 20.0), + value: "ada@example.com", + display: None, + quadding: 0, + }, + |_| {}, + ); let row2 = 531.0; label(&mut c, LEFT, row2 + 24.0, "Date (AFDate_FormatEx yyyy-mm-dd)"); - b.text_field("date", "Date", Rect::new(LEFT, row2, 296.0, row2 + 20.0), "2026-09-30", None, 0, |d| { - d.set("AA", js_actions("AFDate_FormatEx(\"yyyy-mm-dd\");", "AFDate_KeystrokeEx(\"yyyy-mm-dd\");")); - }); + b.text_field( + TextFieldSpec { + field: "date", + tooltip: "Date", + rect: Rect::new(LEFT, row2, 296.0, row2 + 20.0), + value: "2026-09-30", + display: None, + quadding: 0, + }, + |d| { + d.set("AA", js_actions("AFDate_FormatEx(\"yyyy-mm-dd\");", "AFDate_KeystrokeEx(\"yyyy-mm-dd\");")); + }, + ); label(&mut c, COL2, row2 + 24.0, "Amount (AFNumber_Format, right-aligned)"); - b.text_field("amount", "Amount in US dollars", Rect::new(COL2, row2, RIGHT, row2 + 20.0), "1234.5", Some("$1,234.50"), 2, |d| { - d.set("AA", js_actions("AFNumber_Format(2, 0, 0, 0, \"$\", true);", "AFNumber_Keystroke(2, 0, 0, 0, \"$\", true);")); - }); + b.text_field( + TextFieldSpec { + field: "amount", + tooltip: "Amount in US dollars", + rect: Rect::new(COL2, row2, RIGHT, row2 + 20.0), + value: "1234.5", + display: Some("$1,234.50"), + quadding: 2, + }, + |d| { + d.set("AA", js_actions("AFNumber_Format(2, 0, 0, 0, \"$\", true);", "AFNumber_Keystroke(2, 0, 0, 0, \"$\", true);")); + }, + ); label(&mut c, LEFT, 510.0, "Comments (multiline)"); let comments = "PdfCraft renders this field from its own appearance stream, so it looks \ the same in every viewer. Edit it to see your viewer regenerate the text."; - b.text_field("comments", "Comments", Rect::new(LEFT, 446.0, RIGHT, 506.0), comments, None, 0, |d| { - d.set("Ff", 4096); - }); + b.text_field( + TextFieldSpec { + field: "comments", + tooltip: "Comments", + rect: Rect::new(LEFT, 446.0, RIGHT, 506.0), + value: comments, + display: None, + quadding: 0, + }, + |d| { + d.set("Ff", 4096); + }, + ); // --- Preferences ----------------------------------------------------------- section(&mut c, 418.0, "Preferences"); @@ -239,17 +298,8 @@ impl Builder<'_> { /// A text field; `display` overrides the appearance text (for formatted values), /// `quadding` is 0 left, 1 centre, 2 right. - #[allow(clippy::too_many_arguments)] - fn text_field( - &mut self, - field: &str, - tooltip: &str, - r: Rect, - value: &str, - display: Option<&str>, - quadding: i64, - customise: impl FnOnce(&mut Dictionary), - ) { + fn text_field(&mut self, spec: TextFieldSpec<'_>, customise: impl FnOnce(&mut Dictionary)) { + let TextFieldSpec { field, tooltip, rect: r, value, display, quadding } = spec; let (w, h) = (r.w(), r.h()); let mut d = self.widget_base(r); d.set("FT", "Tx"); diff --git a/xtask/src/gates.rs b/xtask/src/gates.rs index 0212e165a5..58efe66777 100644 --- a/xtask/src/gates.rs +++ b/xtask/src/gates.rs @@ -116,7 +116,7 @@ pub fn ci(_: &[String]) -> anyhow::Result<()> { ("i18n-report", Box::new(|| i18n_report(&["--workspace".to_string()]))), ("layers", Box::new(|| layers(&[]))), ("wasm", Box::new(|| wasm(&[]))), - ("assets", Box::new(|| crate::assets::run(&[]))), + ("license-check", Box::new(|| crate::license_check::run(&[]))), ("deny", Box::new(|| deny(&[]))), ("parity", Box::new(|| crate::parity::run(&[]))), ]; diff --git a/xtask/src/license_check.rs b/xtask/src/license_check.rs new file mode 100644 index 0000000000..c845101db6 --- /dev/null +++ b/xtask/src/license_check.rs @@ -0,0 +1,471 @@ +//! Offline source-document checks. This does not inspect completed packages or dependencies. +use std::collections::BTreeSet; +use std::path::Path; + +use anyhow::{Context, Result, bail}; +use serde::Deserialize; + +use crate::assets; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Documents { + document: Vec, + bundled: Vec, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Document { + source: String, + destination: String, + sha256: String, + /// Audited identifiers of the full licence texts in this document, not inferred from its contents. + #[serde(default)] + licences: Vec, + #[serde(default)] + origin: Option, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Bundled { + #[serde(rename = "crate")] + krate: String, + path: String, + licence: String, + documents: Vec, +} + +pub fn run(args: &[String]) -> Result<()> { + if !args.is_empty() { + bail!("usage: cargo xtask license-check (no downloads or write mode)"); + } + let root = assets::root(); + // Reuse the existing policy and generated-attribution validator, once. + assets::run(&[])?; + validate(&root)?; + println!("license-check: source documents and licensing metadata are consistent"); + Ok(()) +} + +fn validate(root: &Path) -> Result<()> { + let text = std::fs::read_to_string(root.join("LICENSES.toml")).context("reading LICENSES.toml")?; + let docs: Documents = toml::from_str(&text).context("parsing LICENSES.toml")?; + let attribution = assets::load(root)?; + let mut problems = check(root, &docs, &attribution); + let ocr_source = std::fs::read_to_string(root.join("crates/ocr/src/lib.rs")).context("reading OCR model filename constants")?; + problems.extend(check_required_ocr_metadata(&ocr_source, &attribution)?); + if !problems.is_empty() { + bail!("source licensing errors:\n{}", problems.join("\n")); + } + Ok(()) +} + +/// Read only the two filename constants consumed by the OCR application. Fail closed if their +/// declaration format changes; do not guess filenames or add a dependency on the OCR engine. +fn check_required_ocr_metadata(source: &str, attribution: &assets::Manifest) -> Result> { + let mut problems = Vec::new(); + for name in ["DETECTION_MODEL", "RECOGNITION_MODEL"] { + let prefix = format!("pub const {name}: &str = \""); + let declarations: Vec<_> = source.lines().map(str::trim).filter_map(|line| line.strip_prefix(&prefix)).collect(); + let file = declarations.first().and_then(|value| value.strip_suffix("\";")); + let Some(file) = file.filter(|file| declarations.len() == 1 && safe_path(file) && !file.contains('/')) else { + bail!( + "OCR constant {name}: expected one literal filename declaration in crates/ocr/src/lib.rs; update the source validator if its syntax changed" + ); + }; + let entries: Vec<_> = attribution.fetched.iter().filter(|f| f.file == file).collect(); + if entries.is_empty() { + problems.push(format!("{file}: required OCR licensing metadata missing from ATTRIBUTION.toml")); + } else { + if entries.len() != 1 { + problems.push(format!("{file}: required OCR licensing metadata must have exactly one entry")); + } + if entries.iter().any(|f| f.kind != "model") { + problems.push(format!("{file}: required OCR licensing metadata must have kind = \"model\"")); + } + } + } + Ok(problems) +} + +/// The audited bundle inventory currently uses identifiers and flat AND/OR expressions only. +/// Keep their semantics distinct, and fail closed on mixed operators, grouping or exceptions +/// rather than introducing a general SPDX parser or flattening an unsupported expression. +fn licence_coverage(expression: &str, covered: &BTreeSet<&str>) -> Result { + let mut tokens = expression.split_whitespace(); + let Some(first) = tokens.next().filter(|id| assets::ALLOWED_LICENCES.contains(id)) else { + bail!("unsupported bundle licence expression `{expression}`"); + }; + let mut ids = vec![first]; + let mut operator = None; + while let Some(op) = tokens.next() { + if !matches!(op, "AND" | "OR") || operator.is_some_and(|previous| previous != op) { + bail!("unsupported bundle licence expression `{expression}`; audit its applicability before extending the validator"); + } + operator = Some(op); + let Some(id) = tokens.next().filter(|id| assets::ALLOWED_LICENCES.contains(id)) else { + bail!("unsupported bundle licence expression `{expression}`"); + }; + ids.push(id); + } + Ok(if operator == Some("OR") { ids.iter().any(|id| covered.contains(id)) } else { ids.iter().all(|id| covered.contains(id)) }) +} + +// A portable relative path, including on Windows; reject aliases before comparing mappings. +fn safe_path(s: &str) -> bool { + !s.is_empty() + && !s.contains(['\\', ':']) + && s.split('/').all(|p| { + !p.is_empty() + && p != "." + && p != ".." + && !p.ends_with(['.', ' ']) + && p.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')) + && !matches!( + p.split('.').next().unwrap_or_default().to_ascii_uppercase().as_str(), + "CON" + | "PRN" + | "AUX" + | "NUL" + | "COM1" + | "COM2" + | "COM3" + | "COM4" + | "COM5" + | "COM6" + | "COM7" + | "COM8" + | "COM9" + | "LPT1" + | "LPT2" + | "LPT3" + | "LPT4" + | "LPT5" + | "LPT6" + | "LPT7" + | "LPT8" + | "LPT9" + ) + }) +} + +fn hash(s: &str) -> bool { + s.len() == 64 && s.bytes().all(|b| b.is_ascii_hexdigit()) +} + +fn check(root: &Path, docs: &Documents, attribution: &assets::Manifest) -> Vec { + let mut problems = Vec::new(); + let mut sources = BTreeSet::new(); + let mut destinations = BTreeSet::new(); + for d in &docs.document { + if !safe_path(&d.source) || !safe_path(&d.destination) { + problems.push(format!("{}: unsafe document source or destination", d.source)); + continue; + } + let new_source = sources.insert(d.source.as_str()); + let new_destination = destinations.insert(d.destination.to_ascii_lowercase()); + if !new_source || !new_destination { + problems.push(format!("{}: duplicate document source or destination", d.source)); + } + if d.origin.as_ref().is_some_and(|u| !u.starts_with("https://") || u.trim().len() <= 8) { + problems.push(format!("{}: invalid document origin", d.source)); + } + let mut identifiers = BTreeSet::new(); + for id in &d.licences { + if !assets::ALLOWED_LICENCES.contains(&id.as_str()) || !identifiers.insert(id.as_str()) { + problems.push(format!("{}: invalid or duplicate audited licence identifier `{id}`", d.source)); + } + } + let path = root.join(&d.source); + // Canonical confinement also catches symlinks escaping the checkout. + let confined = root.canonicalize().and_then(|r| path.canonicalize().map(|p| p.starts_with(r))).unwrap_or(false); + if !confined || !path.is_file() { + problems.push(format!("{}: required document missing or outside checkout", d.source)); + continue; + } + match std::fs::read_to_string(&path) { + Ok(text) if !text.trim().is_empty() && !text.contains('\0') => { + if !hash(&d.sha256) || assets::sha256_hex(text.as_bytes()) != d.sha256 { + problems.push(format!("{}: document hash mismatch; re-audit the source before updating LICENSES.toml", d.source)); + } + } + _ => problems.push(format!("{}: required document empty or invalid UTF-8 text", d.source)), + } + } + for destination in &destinations { + for (i, _) in destination.match_indices('/') { + if destination.get(..i).is_some_and(|prefix| destinations.contains(prefix)) { + problems.push(format!("{destination}: document destination conflicts with a parent file")); + } + } + } + for required in ["LICENSE-MIT", "LICENSE-APACHE", "NOTICE", "vendor/hayro-interpret/assets/LICENSE_FOXIT"] + .into_iter() + .chain(attribution.asset.iter().map(|a| a.licence_file.as_str())) + { + if !sources.contains(required) { + problems.push(format!("{required}: required document has no LICENSES.toml mapping")); + } + } + // One mapping per bundled asset, using the existing attribution's licence expression. + let mut seen = BTreeSet::new(); + for b in &docs.bundled { + if !seen.insert((b.krate.as_str(), b.path.as_str())) { + problems.push(format!("{} {}: duplicate bundled mapping", b.krate, b.path)); + } + if !attribution.bundled.iter().any(|a| a.krate == b.krate && a.path == b.path && a.licence == b.licence) { + problems.push(format!("{} {}: bundled mapping disagrees with ATTRIBUTION.toml", b.krate, b.path)); + } + if b.documents.is_empty() || b.documents.iter().any(|d| !sources.contains(d.as_str())) { + problems.push(format!("{} {}: bundled licence documents missing", b.krate, b.path)); + } + for document in docs.document.iter().filter(|d| b.documents.contains(&d.source)) { + if !document.licences.iter().any(|id| b.licence.split_whitespace().any(|token| token == id)) { + problems.push(format!("{} {}: inapplicable mapped document {}", b.krate, b.path, document.source)); + } + } + let covered: BTreeSet<_> = + docs.document.iter().filter(|d| b.documents.contains(&d.source)).flat_map(|d| d.licences.iter().map(String::as_str)).collect(); + match licence_coverage(&b.licence, &covered) { + Ok(true) => {} + Ok(false) => problems + .push(format!("{} {}: inapplicable licence documents; mapped audited identifiers do not cover `{}`", b.krate, b.path, b.licence)), + Err(error) => problems.push(format!("{} {}: {error}", b.krate, b.path)), + } + } + for b in &attribution.bundled { + if !seen.contains(&(b.krate.as_str(), b.path.as_str())) { + problems.push(format!("{} {}: bundled asset has no licence document mapping", b.krate, b.path)); + } + } + for a in attribution.asset.iter().filter(|a| a.licence_file == "assets/icons/LICENSE-lucide.txt") { + if a.licence != "ISC AND MIT" { + problems.push(format!("{}: Lucide collection must credit ISC and Feather MIT", a.path)); + } + } + check_model_metadata(attribution, &mut problems); + problems +} + +fn check_model_metadata(attribution: &assets::Manifest, problems: &mut Vec) { + let mut model_paths = BTreeSet::new(); + for f in attribution.fetched.iter().filter(|f| f.kind == "model") { + if [f.title.as_str(), &f.author, &f.source, &f.usage, &f.licence].iter().any(|v| v.trim().is_empty()) + || !hash(&f.sha256) + || !hash(&f.licence_sha256) + || !f.url.starts_with("https://") + || !f.licence_url.starts_with("https://") + { + problems.push(format!("{}: incomplete OCR model licensing metadata", f.file)); + } + for path in [f.file.clone(), format!("{}.LICENCE.txt", f.file)] { + if !safe_path(&path) + || path.contains('/') + || path.eq_ignore_ascii_case("ATTRIBUTION.txt") + || !model_paths.insert(path.to_ascii_lowercase()) + { + problems.push(format!("{}: duplicate or unsafe OCR model destination", f.file)); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicU64, Ordering}; + + fn fixture() -> (std::path::PathBuf, Documents) { + static NEXT: AtomicU64 = AtomicU64::new(0); + let root = std::env::temp_dir().join(format!("pdfcraft-license-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed))); + std::fs::create_dir_all(&root).unwrap(); + let document = ["LICENSE-MIT", "LICENSE-APACHE", "NOTICE", "vendor/hayro-interpret/assets/LICENSE_FOXIT"] + .into_iter() + .map(|p| { + let path = root.join(p); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, "synthetic licence text\n").unwrap(); + Document { + source: p.into(), + destination: p.into(), + sha256: assets::sha256_hex(b"synthetic licence text\n"), + licences: match p { + "LICENSE-MIT" => vec!["MIT".into()], + "LICENSE-APACHE" => vec!["Apache-2.0".into()], + _ => vec![], + }, + origin: None, + } + }) + .collect(); + (root, Documents { document, bundled: vec![] }) + } + + #[test] + fn missing_license_fails_and_restoring_recovers() { + let (root, docs) = fixture(); + let m = assets::Manifest::default(); + assert!(check(&root, &docs, &m).is_empty()); + std::fs::remove_file(root.join("LICENSE-MIT")).unwrap(); + assert!(check(&root, &docs, &m).iter().any(|p| p.contains("LICENSE-MIT: required document missing"))); + std::fs::write(root.join("LICENSE-MIT"), "synthetic licence text\n").unwrap(); + assert!(check(&root, &docs, &m).is_empty()); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn empty_invalid_and_changed_documents_fail() { + let (root, docs) = fixture(); + for text in [b" ".as_slice(), b"\xff", b"changed licence", b"text\0"] { + std::fs::write(root.join("NOTICE"), text).unwrap(); + assert!(!check(&root, &docs, &assets::Manifest::default()).is_empty()); + } + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn duplicate_and_unsafe_destinations_fail() { + let (root, mut docs) = fixture(); + for dest in ["license-mit", "../NOTICE", "/NOTICE", "C:/NOTICE", "a\\NOTICE", "a//NOTICE", "CON.txt", "NOTICE."] { + docs.document.get_mut(2).unwrap().destination = dest.into(); + assert!(!check(&root, &docs, &assets::Manifest::default()).is_empty(), "{dest}"); + } + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn removing_required_mapping_fails() { + let (root, mut docs) = fixture(); + docs.document.retain(|d| d.source != "LICENSE-MIT"); + assert!(check(&root, &docs, &assets::Manifest::default()).iter().any(|p| p.contains("LICENSE-MIT: required document has no"))); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn bundled_documents_must_match_attribution() { + let (root, mut docs) = fixture(); + let mut m = assets::Manifest::default(); + m.bundled.push(assets::Bundled { + krate: "synthetic".into(), + version: "1.0.0".into(), + path: "font.ttf".into(), + title: "test".into(), + author: "test".into(), + source: "https://example.org".into(), + licence: "MIT".into(), + kind: "font".into(), + usage: "test".into(), + sha256: "a".repeat(64), + adobe_data: false, + }); + assert!(check(&root, &docs, &m).iter().any(|p| p.contains("no licence document mapping"))); + docs.bundled.push(Bundled { + krate: "synthetic".into(), + path: "font.ttf".into(), + licence: "MIT".into(), + documents: vec!["LICENSE-MIT".into()], + }); + assert!(check(&root, &docs, &m).is_empty()); + docs.bundled.get_mut(0).unwrap().documents = vec!["LICENSE-APACHE".into()]; + assert!(check(&root, &docs, &m).iter().any(|p| p.contains("inapplicable"))); + docs.bundled.get_mut(0).unwrap().documents = vec!["LICENSE-MIT".into(), "LICENSE-APACHE".into()]; + assert!(check(&root, &docs, &m).iter().any(|p| p.contains("inapplicable mapped document"))); + docs.bundled.get_mut(0).unwrap().documents = vec!["LICENSE-MIT".into()]; + docs.bundled.get_mut(0).unwrap().licence = "ISC".into(); + assert!(check(&root, &docs, &m).iter().any(|p| p.contains("disagrees"))); + docs.bundled.get_mut(0).unwrap().documents.clear(); + assert!(check(&root, &docs, &m).iter().any(|p| p.contains("documents missing"))); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn ocr_metadata_and_destinations_are_checked_without_models() { + let (root, docs) = fixture(); + let mut m = assets::Manifest::default(); + m.fetched.push(assets::Fetched { + file: "synthetic.rten".into(), + url: "https://example.org/model".into(), + title: "test model".into(), + author: "test".into(), + source: "https://example.org/card".into(), + licence: "CC-BY-SA-4.0".into(), + licence_url: "https://example.org/licence".into(), + licence_sha256: "a".repeat(64), + kind: "model".into(), + usage: "test".into(), + sha256: "b".repeat(64), + }); + assert!(check(&root, &docs, &m).is_empty()); + m.fetched.get_mut(0).unwrap().licence_sha256 = "z".repeat(64); + assert!(check(&root, &docs, &m).iter().any(|p| p.contains("incomplete OCR"))); + m.fetched.get_mut(0).unwrap().file = "../outside.rten".into(); + assert!(check(&root, &docs, &m).iter().any(|p| p.contains("unsafe OCR"))); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn required_ocr_models_cannot_be_missing() { + let source = include_str!("../../crates/ocr/src/lib.rs"); + let original = assets::load(&assets::root()).unwrap(); + assert_eq!(original.fetched.iter().filter(|f| f.kind == "model").count(), 2); + assert!(check_required_ocr_metadata(source, &original).unwrap().is_empty()); + for model in original.fetched.iter().filter(|f| f.kind == "model") { + let mut m = assets::load(&assets::root()).unwrap(); + m.fetched.retain(|f| f.file != model.file); + assert!(check_required_ocr_metadata(source, &m).unwrap().iter().any(|p| p.contains(&model.file) && p.contains("missing"))); + } + } + + #[test] + fn required_ocr_models_cannot_be_misclassified() { + let source = include_str!("../../crates/ocr/src/lib.rs"); + let original = assets::load(&assets::root()).unwrap(); + assert_eq!(original.fetched.iter().filter(|f| f.kind == "model").count(), 2); + for model in original.fetched.iter().filter(|f| f.kind == "model") { + let mut m = assets::load(&assets::root()).unwrap(); + m.fetched.iter_mut().find(|f| f.file == model.file).unwrap().kind = "font".into(); + assert!(check_required_ocr_metadata(source, &m).unwrap().iter().any(|p| p.contains(&model.file) && p.contains("kind"))); + } + } + + #[test] + fn licence_alternatives_and_combined_obligations_are_distinct() { + let mit = BTreeSet::from(["MIT"]); + assert!(licence_coverage("MIT OR Apache-2.0", &mit).unwrap()); + assert!(!licence_coverage("MIT AND Apache-2.0", &mit).unwrap()); + let both = BTreeSet::from(["MIT", "Bitstream-Vera"]); + assert!(licence_coverage("MIT AND Bitstream-Vera", &both).unwrap()); + assert!(!licence_coverage("MIT OR Apache-2.0", &BTreeSet::from(["OFL-1.1"])).unwrap()); + for expression in ["", "MIT AND", "MIT OR Apache-2.0 AND ISC", "(MIT OR Apache-2.0)", "MIT WITH exception"] { + assert!(licence_coverage(expression, &both).is_err(), "{expression}"); + } + } + + #[test] + fn changed_ocr_constant_syntax_fails_closed() { + assert!(check_required_ocr_metadata("", &assets::Manifest::default()).is_err()); + } + + #[test] + fn parent_file_destination_conflicts_fail() { + let (root, mut docs) = fixture(); + docs.document.get_mut(2).unwrap().destination = "LICENSE-MIT/NOTICE".into(); + assert!(check(&root, &docs, &assets::Manifest::default()).iter().any(|p| p.contains("parent file"))); + std::fs::remove_dir_all(root).unwrap(); + } + + #[cfg(unix)] + #[test] + fn symlink_escape_fails() { + let (root, docs) = fixture(); + let outside = root.with_extension("outside"); + std::fs::write(&outside, "synthetic licence text\n").unwrap(); + std::fs::remove_file(root.join("LICENSE-MIT")).unwrap(); + std::os::unix::fs::symlink(&outside, root.join("LICENSE-MIT")).unwrap(); + assert!(check(&root, &docs, &assets::Manifest::default()).iter().any(|p| p.contains("outside checkout"))); + std::fs::remove_dir_all(root).unwrap(); + std::fs::remove_file(outside).unwrap(); + } +} diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 24ddd16d16..8e3255d5e6 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -8,6 +8,7 @@ mod demo_pdf; mod fuzz; mod gates; mod layers; +mod license_check; mod macos_integration; mod parity; mod screenshots; @@ -23,11 +24,12 @@ const COMMANDS: &[(&str, &str, Command)] = &[ ("deny", "Dependency licences, bans, sources and advisories (deny.toml; needs cargo-deny)", gates::deny), ( "ci", - "fmt --check, clippy -D warnings, the CLI without default features, test, i18n-report, layers, wasm, assets, deny, parity (stops at first failure)", + "fmt --check, clippy -D warnings, the CLI without default features, test, i18n-report, layers, wasm, license-check, deny, parity (stops at first failure)", gates::ci, ), ("test", "Workspace tests: cargo nextest (cargo test when it is missing), then doctests", gates::test), ("i18n-report", "Print the strings each interface language lacks (counts and a few keys); never fails", gates::i18n_report), + ("license-check", "Offline source licence documents, attribution and metadata", license_check::run), ("assets", "Enforce the asset policy (AGENTS.md §1) against ATTRIBUTION.toml; --write regenerates ATTRIBUTION.md", assets::run), ("corpus", "Fetch the pinned pdf.js test corpus into corpus/ (git-ignored) and verify it; --update-pin re-records the pin", gates::corpus), ("check", "Robustness sweep over corpus/ with pdfcraft-cli; fails on crashes or regressions vs xtask/baselines", gates::check), diff --git a/xtask/src/parity.rs b/xtask/src/parity.rs index ac32f32e85..39e0cac8de 100644 --- a/xtask/src/parity.rs +++ b/xtask/src/parity.rs @@ -229,6 +229,16 @@ pub fn run(args: &[String]) -> Result<()> { ); } } + // A warning, not an error: shipped means exists and tested, and the notes say what is still missing (CLAUDE.md). + let without_notes: Vec<&str> = + file.features.iter().filter(|f| f.status == "shipped" && f.notes.trim().is_empty()).map(|f| f.id.as_str()).collect(); + if !without_notes.is_empty() && !args.iter().any(|a| a == "--json") { + eprintln!( + "parity warning: {} shipped feature(s) have empty notes (say what is still missing): {}", + without_notes.len(), + without_notes.join(", ") + ); + } if !errors.is_empty() { for e in &errors { eprintln!("parity error: {e}");